Merge branch 'dev' into ab
This commit is contained in:
commit
65e69cd40a
|
|
@ -0,0 +1,133 @@
|
|||
name: Nightly Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 22 * * *" # Runs at 10 PM UTC every day
|
||||
|
||||
env:
|
||||
VITE_WEB_URL: ${{ vars.VITE_WEB_URL }}
|
||||
VITE_API_URL: ${{ vars.VITE_API_URL }}
|
||||
VITE_IMGPROXY_URL: ${{ vars.VITE_IMGPROXY_URL }}
|
||||
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ vars.VITE_POSTHOG_KEY }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
jobs:
|
||||
nightly-release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
lfs: true
|
||||
|
||||
- name: Cache pnpm modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ matrix.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ matrix.os }}-
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install appdmg
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm add -g appdmg
|
||||
|
||||
# ... (macOS certificate and provisioning profile setup steps) ...
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Set nightly version
|
||||
run: |
|
||||
# Get the current version from package.json
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
# Remove any existing prerelease identifier (e.g., -alpha.1)
|
||||
BASE_VERSION=$(echo $CURRENT_VERSION | sed -E 's/(-[a-zA-Z]+\.[0-9]+)$//')
|
||||
# Generate the nightly version
|
||||
NIGHTLY_DATE=$(date +'%Y%m%d')
|
||||
NIGHTLY_VERSION="${BASE_VERSION}-nightly.${NIGHTLY_DATE}"
|
||||
echo "NIGHTLY_VERSION=$NIGHTLY_VERSION" >> $GITHUB_ENV
|
||||
# Update version in package.json
|
||||
sed -i.bak -e "s/\"version\": \".*\"/\"version\": \"$NIGHTLY_VERSION\"/" package.json
|
||||
echo "Updated version to $NIGHTLY_VERSION"
|
||||
|
||||
- name: Build
|
||||
if: matrix.os != 'macos-latest'
|
||||
run: pnpm build
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Build (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db
|
||||
run: pnpm build:macos
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.os }}-nightly
|
||||
path: |
|
||||
out/make/**/*.zip
|
||||
out/make/**/*.exe
|
||||
out/make/**/*.AppImage
|
||||
out/make/**/*.yml
|
||||
out/make/**/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate artifact attestation
|
||||
continue-on-error: true
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-path: |
|
||||
out/make/**/*.dmg
|
||||
out/make/**/*.zip
|
||||
out/make/**/*.exe
|
||||
out/make/**/*.AppImage
|
||||
out/make/**/*.yml
|
||||
|
||||
- name: Create Nightly Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: Nightly ${{ env.NIGHTLY_VERSION }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
tag_name: nightly-${{ env.NIGHTLY_VERSION }}
|
||||
files: |
|
||||
out/make/**/*.dmg
|
||||
out/make/**/*.zip
|
||||
out/make/**/*.exe
|
||||
out/make/**/*.AppImage
|
||||
out/make/**/*.yml
|
||||
body: |
|
||||
This is an automated nightly release for testing purposes.
|
||||
Version: 0.0.0-nightly.${{ env.NIGHTLY_VERSION }}
|
||||
|
||||
**Warning:** This build may be unstable and is not recommended for production use.
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
packages/shared/src/hono.ts
|
||||
pnpm-lock.yaml
|
||||
|
||||
CHANGELOG.md
|
||||
|
|
|
|||
2485
CHANGELOG.md
2485
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
|
|
@ -128,3 +128,5 @@ Since it is not very convenient to develop in Electron, the first way to develop
|
|||
Follow is licensed under the GNU General Public License version 3 with the addition of the following special exception:
|
||||
|
||||
All content in the `icons/mgc` directory is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed.
|
||||
|
||||
All content in the `lottie` directory is distributed under the [Lottie Simple License](https://lottiefiles.com/page/license).
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsup && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -26,8 +27,6 @@
|
|||
"@follow/shared": "workspace:*",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"@sentry/electron": "5.4.0",
|
||||
"@yornaath/batshit": "0.10.1",
|
||||
"bufferutil": "4.0.8",
|
||||
"builder-util-runtime": "9.2.5",
|
||||
"dayjs": "1.11.13",
|
||||
"dotenv": "16.4.5",
|
||||
|
|
@ -37,32 +36,23 @@
|
|||
"electron-updater": "^6.3.4",
|
||||
"font-list": "1.5.1",
|
||||
"i18next": "^23.15.1",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"idb-keyval": "6.2.1",
|
||||
"linkedom": "^0.18.4",
|
||||
"linkedom": "^0.18.5",
|
||||
"lodash-es": "4.17.21",
|
||||
"lowdb": "7.0.1",
|
||||
"msedge-tts": "1.3.4",
|
||||
"nanoid": "5.0.7",
|
||||
"ofetch": "1.3.4",
|
||||
"posthog-js": "1.161.5",
|
||||
"ofetch": "1.4.0",
|
||||
"posthog-js": "1.163.0",
|
||||
"posthog-node": "4.2.0",
|
||||
"semver": "7.6.3",
|
||||
"tldts": "6.1.46",
|
||||
"vscode-languagedetection": "npm:@vscode/vscode-languagedetection@^1.0.22",
|
||||
"zod": "3.23.8"
|
||||
"vscode-languagedetection": "npm:@vscode/vscode-languagedetection@^1.0.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/generator": "7.25.6",
|
||||
"@hono/node-server": "1.13.0",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/node": "^22.5.5",
|
||||
"drizzle-orm": "0.33.0",
|
||||
"electron": "32.1.0",
|
||||
"electron": "32.1.2",
|
||||
"electron-devtools-installer": "3.2.0",
|
||||
"hono": "4.6.2",
|
||||
"rimraf": "6.0.1",
|
||||
"tsx": "4.19.1",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { callGlobalContextMethod } from "@follow/shared/bridge"
|
||||
import { dialog } from "electron"
|
||||
|
||||
import { getMainWindow } from "~/window"
|
||||
|
||||
import { t } from "./i18n"
|
||||
|
||||
export const clearAllData = async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return
|
||||
const ses = win.webContents.session
|
||||
|
||||
// Dialog to confirm
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
|
||||
message: t("dialog.clearAllData"),
|
||||
buttons: [t("dialog.yes"), t("dialog.no")],
|
||||
})
|
||||
|
||||
if (result.response === 1) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ses.clearCache()
|
||||
|
||||
await ses.clearStorageData({
|
||||
storages: [
|
||||
"websql",
|
||||
"filesystem",
|
||||
"indexdb",
|
||||
"localstorage",
|
||||
"shadercache",
|
||||
"websql",
|
||||
"serviceworkers",
|
||||
"cookies",
|
||||
],
|
||||
})
|
||||
callGlobalContextMethod(win, "toast.success", ["App data reset successfully"])
|
||||
|
||||
// reload the app
|
||||
win.reload()
|
||||
} catch (error: any) {
|
||||
callGlobalContextMethod(win, "toast.error", [`Error resetting app data: ${error.message}`])
|
||||
}
|
||||
}
|
||||
|
|
@ -16,10 +16,10 @@ import { store } from "./store"
|
|||
|
||||
export const setProxyConfig = (inputProxy: string) => {
|
||||
const proxyUri = normalizeProxyUri(inputProxy)
|
||||
store.set("proxy", proxyUri)
|
||||
if (!proxyUri) {
|
||||
return false
|
||||
}
|
||||
store.set("proxy", inputProxy)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { BrowserWindow, MenuItem, MenuItemConstructorOptions } from "electr
|
|||
import { Menu } from "electron"
|
||||
|
||||
import { isDev, isMacOS } from "./env"
|
||||
import { clearAllData } from "./lib/cleaner"
|
||||
import { t } from "./lib/i18n"
|
||||
import { revealLogFile } from "./logger"
|
||||
import { checkForUpdates, quitAndInstall } from "./updater"
|
||||
|
|
@ -35,6 +36,10 @@ export const registerAppMenu = () => {
|
|||
{ role: "hide", label: t("menu.hide", { name }) },
|
||||
{ role: "hideOthers", label: t("menu.hideOthers") },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("menu.clearAllData"),
|
||||
click: clearAllData,
|
||||
},
|
||||
{ role: "quit", label: t("menu.quit", { name }) },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { BrowserWindow } from "electron"
|
|||
import { app, clipboard, dialog, screen } from "electron"
|
||||
|
||||
import { registerMenuAndContextMenu } from "~/init"
|
||||
import { clearAllData } from "~/lib/cleaner"
|
||||
|
||||
import { isWindows11 } from "../env"
|
||||
import { downloadFile } from "../lib/download"
|
||||
|
|
@ -218,6 +219,8 @@ export const appRoute = {
|
|||
|
||||
app.commandLine.appendSwitch("lang", input)
|
||||
}),
|
||||
|
||||
clearAllData: t.procedure.action(clearAllData),
|
||||
}
|
||||
|
||||
interface Sender extends Electron.WebContents {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
"@egjs/react-infinitegrid": "4.12.0",
|
||||
"@egoist/tipc": "0.3.2",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@follow/electron-main": "workspace:*",
|
||||
"@follow/shared": "workspace:*",
|
||||
"@fontsource/sn-pro": "5.1.0",
|
||||
|
|
@ -19,6 +18,7 @@
|
|||
"@hono/auth-js": "1.0.10",
|
||||
"@hookform/resolvers": "3.9.0",
|
||||
"@iconify/tools": "4.0.6",
|
||||
"@lottiefiles/dotlottie-react": "0.8.12",
|
||||
"@microflash/remark-callout-directives": "4.3.1",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"@radix-ui/react-alert-dialog": "1.1.1",
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
"@radix-ui/react-tooltip": "1.1.2",
|
||||
"@sentry/react": "8.30.0",
|
||||
"@sentry/vite-plugin": "2.22.4",
|
||||
"@shikijs/transformers": "1.17.7",
|
||||
"@shikijs/transformers": "1.18.0",
|
||||
"@t3-oss/env-core": "^0.11.1",
|
||||
"@tanstack/query-sync-storage-persister": "5.56.2",
|
||||
"@tanstack/react-query": "5.56.2",
|
||||
|
|
@ -49,8 +49,6 @@
|
|||
"@tanstack/react-query-persist-client": "5.56.2",
|
||||
"@use-gesture/react": "10.3.1",
|
||||
"@yornaath/batshit": "0.10.1",
|
||||
"bufferutil": "4.0.8",
|
||||
"builder-util-runtime": "9.2.5",
|
||||
"class-variance-authority": "0.7.0",
|
||||
"click-to-react-component": "1.1.0",
|
||||
"clsx": "2.1.1",
|
||||
|
|
@ -58,10 +56,9 @@
|
|||
"dayjs": "1.11.13",
|
||||
"dexie": "4.0.8",
|
||||
"dnum": "^2.13.1",
|
||||
"dotenv": "16.4.5",
|
||||
"electron-log": "5.2.0",
|
||||
"foxact": "0.2.38",
|
||||
"framer-motion": "11.5.4",
|
||||
"framer-motion": "11.5.6",
|
||||
"franc-min": "6.2.0",
|
||||
"fuse.js": "7.0.0",
|
||||
"hast-util-to-jsx-runtime": "2.3.0",
|
||||
|
|
@ -70,15 +67,14 @@
|
|||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"idb-keyval": "6.2.1",
|
||||
"immer": "10.1.1",
|
||||
"jotai": "2.9.3",
|
||||
"jotai": "2.10.0",
|
||||
"lethargy": "1.0.9",
|
||||
"linkedom": "^0.18.4",
|
||||
"linkedom": "^0.18.5",
|
||||
"lodash-es": "4.17.21",
|
||||
"msedge-tts": "1.3.4",
|
||||
"nanoid": "5.0.7",
|
||||
"ofetch": "1.3.4",
|
||||
"ofetch": "1.4.0",
|
||||
"path-to-regexp": "8.1.0",
|
||||
"posthog-js": "1.161.5",
|
||||
"posthog-js": "1.163.0",
|
||||
"re-resizable": "6.9.18",
|
||||
"react-error-boundary": "4.0.13",
|
||||
"react-fast-marquee": "1.6.5",
|
||||
|
|
@ -99,27 +95,25 @@
|
|||
"remark-gfm": "4.0.0",
|
||||
"remark-gh-alerts": "0.0.3",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-rehype": "11.1.0",
|
||||
"semver": "7.6.3",
|
||||
"shiki": "1.17.7",
|
||||
"remark-rehype": "11.1.1",
|
||||
"shiki": "1.18.0",
|
||||
"sonner": "^1.5.0",
|
||||
"swiper": "11.1.14",
|
||||
"tailwind-merge": "2.5.2",
|
||||
"tldts": "6.1.46",
|
||||
"tldts": "6.1.47",
|
||||
"unified": "11.0.5",
|
||||
"use-context-selector": "2.0.0",
|
||||
"usehooks-ts": "3.1.0",
|
||||
"utf-8-validate": "6.0.4",
|
||||
"vfile": "6.0.3",
|
||||
"zod": "3.23.8",
|
||||
"zustand": "4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/generator": "7.25.6",
|
||||
"@hono/node-server": "1.13.0",
|
||||
"@hono/node-server": "1.13.1",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/react": "^18.3.7",
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"fake-indexeddb": "6.0.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
const langs = [
|
||||
// This file is auto-generated by scripts/generate-i18n-locale.ts
|
||||
// DONT EDIT THIS FILE MANUALLY
|
||||
export const currentSupportedLanguages = [
|
||||
"en",
|
||||
"ja",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"zh-HK",
|
||||
"pt",
|
||||
"fr",
|
||||
"ar-DZ",
|
||||
|
|
@ -11,13 +14,12 @@ const langs = [
|
|||
"ar-IQ",
|
||||
"ar-KW",
|
||||
"ar-TN",
|
||||
"zh-HK",
|
||||
"fi",
|
||||
"it",
|
||||
"ru",
|
||||
"es",
|
||||
]
|
||||
export const currentSupportedLanguages = ["en", ...langs.sort()]
|
||||
|
||||
export const dayjsLocaleImportMap = {
|
||||
en: ["en", () => import("dayjs/locale/en")],
|
||||
["zh-CN"]: ["zh-cn", () => import("dayjs/locale/zh-cn")],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// This file is auto-generated by scripts/generate-i18n-locale.ts
|
||||
// DONT EDIT THIS FILE MANUALLY
|
||||
import en from "../../../../locales/app/en.json"
|
||||
import common_ardz from "../../../../locales/common/ar-DZ.json"
|
||||
import common_ariq from "../../../../locales/common/ar-IQ.json"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const createDefaultSettings = (): UISettings => ({
|
|||
modalDraggable: true,
|
||||
modalOpaque: true,
|
||||
reduceMotion: false,
|
||||
usePointerCursor: false,
|
||||
|
||||
// Font
|
||||
uiFontFamily: "SN Pro",
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export const ShadowDOM: FC<PropsWithChildren<React.HTMLProps<HTMLElement>>> & {
|
|||
|
||||
const uiFont = useUISettingKey("uiFontFamily")
|
||||
const reduceMotion = useReduceMotion()
|
||||
const usePointerCursor = useUISettingKey("usePointerCursor")
|
||||
|
||||
return (
|
||||
<root.div {...rest}>
|
||||
|
|
@ -111,8 +112,9 @@ export const ShadowDOM: FC<PropsWithChildren<React.HTMLProps<HTMLElement>>> & {
|
|||
style={useMemo(
|
||||
() => ({
|
||||
fontFamily: `${uiFont},"SN Pro", system-ui, sans-serif`,
|
||||
"--pointer": usePointerCursor ? "pointer" : "default",
|
||||
}),
|
||||
[uiFont],
|
||||
[uiFont, usePointerCursor],
|
||||
)}
|
||||
id="shadow-html"
|
||||
data-motion-reduce={reduceMotion}
|
||||
|
|
@ -151,25 +153,24 @@ function getLinkedStaticStyleSheets() {
|
|||
if (!sheet) continue
|
||||
if (!sheet.href) continue
|
||||
const hasCache = cacheCssTextMap[sheet.href]
|
||||
try {
|
||||
if (!hasCache) {
|
||||
if (!sheet.href) continue
|
||||
if (!hasCache) {
|
||||
if (!sheet.href) continue
|
||||
try {
|
||||
const rules = sheet.cssRules || sheet.rules
|
||||
let cssText = ""
|
||||
for (const rule of rules) {
|
||||
cssText += rule.cssText
|
||||
}
|
||||
|
||||
cacheCssTextMap[sheet.href] = cssText
|
||||
cssArray.push({
|
||||
cssText: cacheCssTextMap[sheet.href],
|
||||
ref: $link,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to get cssText for", sheet.href, err)
|
||||
}
|
||||
} catch (e) {
|
||||
console.info("Parse css error", sheet.href)
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
cssArray.push({
|
||||
cssText: cacheCssTextMap[sheet.href],
|
||||
ref: $link,
|
||||
})
|
||||
}
|
||||
|
||||
return cssArray
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ import { useWhoami } from "~/atoms/user"
|
|||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
import { cn } from "~/lib/utils"
|
||||
import type { FeedModel } from "~/models"
|
||||
import type { TargetModel } from "~/models"
|
||||
import { usePresentUserProfileModal } from "~/modules/profile/hooks"
|
||||
|
||||
export const FeedCertification = ({ feed, className }: { feed: FeedModel; className?: string }) => {
|
||||
export const FeedCertification = ({
|
||||
feed,
|
||||
className,
|
||||
}: {
|
||||
feed: TargetModel
|
||||
className?: string
|
||||
}) => {
|
||||
const me = useWhoami()
|
||||
const presentUserProfile = usePresentUserProfileModal("drawer")
|
||||
const { t } = useTranslation()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { forwardRef, useMemo } from "react"
|
|||
import { getColorScheme, stringToHue } from "~/lib/color"
|
||||
import { getImageProxyUrl } from "~/lib/img-proxy"
|
||||
import { cn, getUrlIcon } from "~/lib/utils"
|
||||
import type { CombinedEntryModel, FeedModel } from "~/models"
|
||||
import type { CombinedEntryModel, FeedModel, TargetModel } from "~/models"
|
||||
|
||||
import { PlatformIcon } from "./ui/platform-icon"
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export function FeedIcon({
|
|||
siteUrl,
|
||||
useMedia,
|
||||
}: {
|
||||
feed?: FeedModel
|
||||
feed?: TargetModel
|
||||
entry?: CombinedEntryModel["entries"]
|
||||
fallbackUrl?: string
|
||||
className?: string
|
||||
|
|
@ -98,8 +98,9 @@ export function FeedIcon({
|
|||
}
|
||||
|
||||
const colors = useMemo(
|
||||
() => getColorScheme(stringToHue(feed?.title || feed?.url || siteUrl!), true),
|
||||
[feed?.title, feed?.url, siteUrl],
|
||||
() =>
|
||||
getColorScheme(stringToHue(feed?.title || (feed as FeedModel)?.url || siteUrl || ""), true),
|
||||
[feed?.title, (feed as FeedModel)?.url, siteUrl],
|
||||
)
|
||||
let ImageElement: ReactNode
|
||||
let finalSrc = ""
|
||||
|
|
@ -109,6 +110,32 @@ export function FeedIcon({
|
|||
height: size,
|
||||
}
|
||||
|
||||
const fallbackIcon = (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
...sizeStyle,
|
||||
"--fo-light-background": colors.light.background,
|
||||
"--fo-dark-background": colors.dark.background,
|
||||
} as any
|
||||
}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-sm",
|
||||
"bg-[var(--fo-light-background)] text-white dark:bg-[var(--fo-dark-background)]",
|
||||
"mr-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: size / 2,
|
||||
}}
|
||||
>
|
||||
{!!feed?.title && feed.title[0]}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
switch (true) {
|
||||
case !feed && !!siteUrl: {
|
||||
const [src] = getFeedIconSrc({
|
||||
|
|
@ -137,9 +164,9 @@ export function FeedIcon({
|
|||
break
|
||||
}
|
||||
case !!fallbackUrl:
|
||||
case !!feed?.siteUrl: {
|
||||
case !!(feed as FeedModel)?.siteUrl: {
|
||||
const [src, fallbackSrc] = getFeedIconSrc({
|
||||
siteUrl: feed?.siteUrl || fallbackUrl,
|
||||
siteUrl: (feed as FeedModel)?.siteUrl || fallbackUrl,
|
||||
fallback,
|
||||
proxy: {
|
||||
width: size * 2,
|
||||
|
|
@ -150,7 +177,7 @@ export function FeedIcon({
|
|||
|
||||
ImageElement = (
|
||||
<PlatformIcon
|
||||
url={feed?.siteUrl || fallbackUrl}
|
||||
url={(feed as FeedModel)?.siteUrl || fallbackUrl}
|
||||
style={sizeStyle}
|
||||
className={cn("center mr-2", className)}
|
||||
>
|
||||
|
|
@ -163,6 +190,10 @@ export function FeedIcon({
|
|||
)
|
||||
break
|
||||
}
|
||||
case !!feed?.title && !!feed.title[0]: {
|
||||
ImageElement = fallbackIcon
|
||||
break
|
||||
}
|
||||
default: {
|
||||
ImageElement = <i className="i-mgc-link-cute-re mr-2 shrink-0" style={sizeStyle} />
|
||||
break
|
||||
|
|
@ -183,25 +214,7 @@ export function FeedIcon({
|
|||
>
|
||||
{ImageElement}
|
||||
</AvatarImage>
|
||||
<AvatarFallback asChild>
|
||||
<span
|
||||
style={
|
||||
{
|
||||
...sizeStyle,
|
||||
"--fo-light-background": colors.light.background,
|
||||
"--fo-dark-background": colors.dark.background,
|
||||
} as any
|
||||
}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-sm",
|
||||
"bg-[var(--fo-light-background)] text-white dark:bg-[var(--fo-dark-background)]",
|
||||
"mr-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-[9px]">{!!feed?.title && feed.title[0]}</span>
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
<AvatarFallback asChild>{fallbackIcon}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { WEB_URL } from "@follow/shared/constants"
|
||||
|
||||
import { FeedIcon } from "~/components/feed-icon"
|
||||
import { cn } from "~/lib/utils"
|
||||
import type { FeedModel } from "~/models"
|
||||
import type { TargetModel } from "~/models"
|
||||
|
||||
import { FeedCertification } from "./feed-certification"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "./ui/typography"
|
||||
|
|
@ -10,14 +12,15 @@ export function FollowSummary({
|
|||
docs,
|
||||
className,
|
||||
}: {
|
||||
feed: FeedModel
|
||||
feed: TargetModel
|
||||
docs?: string
|
||||
className?: string
|
||||
}) {
|
||||
const isList = feed.type === "list"
|
||||
return (
|
||||
<div className={cn("flex select-text flex-col gap-2 text-sm", className)}>
|
||||
<a
|
||||
href={feed.siteUrl || void 0}
|
||||
href={!isList ? feed.siteUrl || void 0 : `${WEB_URL}/list/${feed.id}`}
|
||||
target="_blank"
|
||||
className="flex items-center"
|
||||
rel="noreferrer"
|
||||
|
|
@ -40,8 +43,12 @@ export function FollowSummary({
|
|||
</a>
|
||||
<div className="flex items-center gap-1 truncate text-zinc-500">
|
||||
<i className="i-mgc-right-cute-re shrink-0" />
|
||||
<a href={feed.url || docs} target="_blank" rel="noreferrer">
|
||||
{feed.url || docs}
|
||||
<a
|
||||
href={!isList ? feed.url || docs : `${WEB_URL}/list/${feed.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{!isList ? feed.url || docs : `list:${feed.id}`}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import type { SVGProps } from "react"
|
||||
|
||||
export function LetsIconsResizeDownRightLight(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
<g fill="none" stroke="currentColor">
|
||||
<path d="M10 20h10V10" />
|
||||
<path d="M12 17h5v-5" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,9 +21,11 @@ export interface AutocompleteProps extends React.InputHTMLAttributes<HTMLInputEl
|
|||
|
||||
// classnames
|
||||
|
||||
searchKeys?: string[]
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
const defaultSearchKeys = ["name", "value"]
|
||||
const defaultRenderSuggestion = (suggestion: any) => suggestion?.name
|
||||
export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
|
||||
(
|
||||
|
|
@ -34,7 +36,7 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
|
|||
maxHeight,
|
||||
portal,
|
||||
value,
|
||||
|
||||
searchKeys = defaultSearchKeys,
|
||||
defaultValue,
|
||||
...inputProps
|
||||
},
|
||||
|
|
@ -48,7 +50,7 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
|
|||
|
||||
const doFilter = useCallback(() => {
|
||||
const fuse = new Fuse(suggestions, {
|
||||
keys: ["name", "value"],
|
||||
keys: searchKeys,
|
||||
})
|
||||
|
||||
const trimInputValue = (value as string)?.trim()
|
||||
|
|
@ -56,8 +58,9 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
|
|||
if (!trimInputValue) return setFilterableSuggestions(suggestions)
|
||||
|
||||
const results = fuse.search(trimInputValue)
|
||||
|
||||
setFilterableSuggestions(results.map((result) => result.item))
|
||||
}, [suggestions, value])
|
||||
}, [suggestions, value, searchKeys])
|
||||
useEffect(() => {
|
||||
doFilter()
|
||||
}, [doFilter])
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export const ActionButton = React.forwardRef<
|
|||
className={cn(
|
||||
"no-drag-region inline-flex size-8 items-center justify-center text-xl",
|
||||
active && "bg-zinc-500/15 hover:bg-zinc-500/20",
|
||||
"focus-visible:bg-zinc-500/30 focus-visible:!outline-none",
|
||||
//"focus-visible:bg-zinc-500/30 focus-visible:!outline-none",
|
||||
"rounded-md duration-200 hover:bg-theme-button-hover data-[state=open]:bg-theme-button-hover",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
|
|
@ -163,14 +163,19 @@ export const Button = React.forwardRef<
|
|||
{...props}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<m.span className="center">
|
||||
{isLoading && (
|
||||
<m.span className="center">
|
||||
<LoadingCircle size="small" className="center mr-2" />
|
||||
<span className="contents">
|
||||
{typeof isLoading === "boolean" && (
|
||||
<m.span
|
||||
className="center"
|
||||
animate={{
|
||||
width: isLoading ? "auto" : "0px",
|
||||
}}
|
||||
>
|
||||
{isLoading && <LoadingCircle size="small" className="center mr-2" />}
|
||||
</m.span>
|
||||
)}
|
||||
<m.span className={cn("center", className)}>{props.children}</m.span>
|
||||
</m.span>
|
||||
<span className={cn("center", className)}>{props.children}</span>
|
||||
</span>
|
||||
</MotionButtonBase>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { cn } from "~/lib/utils"
|
|||
|
||||
export const styledButtonVariant = cva(
|
||||
[
|
||||
"inline-flex cursor-default select-none items-center justify-center gap-2 outline-offset-2 transition active:transition-none disabled:cursor-not-allowed",
|
||||
"inline-flex cursor-button select-none items-center justify-center outline-offset-2 transition active:transition-none disabled:cursor-not-allowed",
|
||||
"ring-accent/20 duration-200 disabled:ring-0",
|
||||
"align-middle",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ const ShikiCode: FC<
|
|||
className={"absolute right-2 top-2 opacity-0 duration-200 group-hover:opacity-100"}
|
||||
/>
|
||||
{language !== "plaintext" && (
|
||||
<span className="center absolute bottom-2 right-2 flex gap-1 text-xs uppercase text-white opacity-80">
|
||||
<span className="center absolute bottom-2 right-2 flex gap-1 text-xs uppercase opacity-80 dark:text-white">
|
||||
<span className="center [&_svg]:size-4">{getLanguageIcon(language)}</span>
|
||||
<span>{language}</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
|||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-theme-item-hover focus:text-theme-foreground data-[state=open]:bg-theme-item-hover data-[state=open]:text-theme-foreground",
|
||||
"flex cursor-menu select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-theme-item-hover focus:text-theme-foreground data-[state=open]:bg-theme-item-hover data-[state=open]:text-theme-foreground",
|
||||
inset && "pl-8",
|
||||
"center gap-2",
|
||||
className,
|
||||
|
|
@ -81,7 +81,7 @@ const ContextMenuItem = React.forwardRef<
|
|||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-theme-item-hover focus:text-theme-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-menu select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-theme-item-hover focus:text-theme-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"focus-within:outline-transparent data-[highlighted]:bg-theme-item-hover dark:data-[highlighted]:bg-neutral-800",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
|
|
@ -98,7 +98,7 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
|||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-theme-item-hover focus:text-theme-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-checkbox select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-theme-item-hover focus:text-theme-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"focus-within:outline-transparent",
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { useMeasure } from "~/hooks/common"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"
|
||||
|
||||
export const PanelSplitter = (
|
||||
props: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
isDragging?: boolean
|
||||
cursor?: string
|
||||
|
||||
tooltip?: React.ReactNode
|
||||
},
|
||||
) => {
|
||||
const { isDragging, cursor, ...rest } = props
|
||||
const { isDragging, cursor, tooltip, ...rest } = props
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDragging) return
|
||||
|
|
@ -26,16 +31,30 @@ export const PanelSplitter = (
|
|||
}
|
||||
}, [cursor, isDragging])
|
||||
|
||||
const [ref, { height }] = useMeasure()
|
||||
|
||||
const El = (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"absolute inset-0 z-[3] w-[2px] -translate-x-1/2 cursor-ew-resize bg-transparent hover:bg-gray-400 active:!bg-accent hover:dark:bg-neutral-500",
|
||||
isDragging ? "bg-accent" : "",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-0 shrink-0">
|
||||
<div
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"absolute inset-0 z-[11] w-[2px] -translate-x-1/2 cursor-ew-resize bg-transparent hover:bg-gray-400 active:!bg-accent hover:dark:bg-neutral-500",
|
||||
isDragging ? "bg-accent" : "",
|
||||
)}
|
||||
/>
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{El}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={height / 2}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
El
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-theme-item-hover data-[state=open]:bg-theme-item-active",
|
||||
"flex cursor-menu select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-theme-item-hover data-[state=open]:bg-theme-item-active",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
|
|
@ -80,7 +80,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-theme-item-hover data-[disabled]:pointer-events-none data-[state=open]:bg-theme-item-active data-[disabled]:opacity-50",
|
||||
"relative flex cursor-menu select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-theme-item-hover data-[disabled]:pointer-events-none data-[state=open]:bg-theme-item-active data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
"focus-within:!outline-transparent",
|
||||
className,
|
||||
|
|
@ -104,7 +104,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-theme-item-hover data-[disabled]:pointer-events-none data-[state=open]:bg-theme-item-active data-[disabled]:opacity-50",
|
||||
"relative flex cursor-menu select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-theme-item-hover data-[disabled]:pointer-events-none data-[state=open]:bg-theme-item-active data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import { AudioPlayer } from "~/atoms/player"
|
||||
import { nextFrame } from "~/lib/dom"
|
||||
import { useEntryContentContext } from "~/modules/entry-content/hooks"
|
||||
import { useEntry } from "~/store/entry"
|
||||
|
||||
import { timeStringToSeconds } from "../utils"
|
||||
|
||||
export const TimeStamp = (props: { time: string }) => {
|
||||
const { entryId, audioSrc: src } = useEntryContentContext()
|
||||
|
||||
const entry = useEntry(entryId)
|
||||
const mediaDuration = entry?.entries.attachments?.[0]?.duration_in_seconds
|
||||
|
||||
if (!src) return <span>{props.time}</span>
|
||||
|
||||
const seekTo = timeStringToSeconds(props.time)
|
||||
if (typeof seekTo !== "number") return <span>{props.time}</span>
|
||||
|
||||
return (
|
||||
<span
|
||||
className="cursor-pointer tabular-nums text-accent dark:text-theme-accent-500"
|
||||
|
|
@ -18,11 +26,66 @@ export const TimeStamp = (props: { time: string }) => {
|
|||
src,
|
||||
currentTime: 0,
|
||||
})
|
||||
nextFrame(() => AudioPlayer.seek(timeStringToSeconds(props.time) || 0))
|
||||
nextFrame(() => AudioPlayer.seek(seekTo))
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-time-cute-re mr-1 translate-y-0.5" />
|
||||
{!!mediaDuration && (
|
||||
<CircleProgress
|
||||
className="mr-1 inline translate-y-px scale-95 align-text-top"
|
||||
percent={(seekTo / mediaDuration) * 100}
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
{props.time}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface CircleProgressProps {
|
||||
percent: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
strokeColor?: string
|
||||
backgroundColor?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CircleProgress: React.FC<CircleProgressProps> = ({
|
||||
percent,
|
||||
size = 100,
|
||||
strokeWidth = 8,
|
||||
strokeColor = "hsl(var(--fo-a))",
|
||||
backgroundColor = "hsl(var(--fo-inactive))",
|
||||
className,
|
||||
}) => {
|
||||
const normalizedPercent = Math.min(100, Math.max(0, percent))
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (normalizedPercent / 100) * circumference
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={className}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={backgroundColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
style={{ transition: "stroke-dashoffset 0.3s" }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ const safeUrl = (url: string, baseUrl: string) => {
|
|||
export const MarkdownLink = (props: LinkProps) => {
|
||||
const { view, feedId } = useEntryContentContext()
|
||||
|
||||
const feedSiteUrl = useFeedByIdSelector(feedId, (feed) => feed?.siteUrl)
|
||||
const feedSiteUrl = useFeedByIdSelector(feedId, (feed) =>
|
||||
"siteUrl" in feed ? feed.siteUrl : undefined,
|
||||
)
|
||||
|
||||
const populatedFullHref = useMemo(() => {
|
||||
const { href } = props
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
onError={errorHandle}
|
||||
className={cn(
|
||||
!(props.width || props.height) && "size-full",
|
||||
"bg-gray-200 object-cover duration-200 dark:bg-neutral-800",
|
||||
"cursor-card bg-gray-200 object-cover duration-200 dark:bg-neutral-800",
|
||||
popper && "cursor-zoom-in",
|
||||
mediaLoadState === "loaded" ? "opacity-100" : "opacity-0",
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
className={cn(
|
||||
"center",
|
||||
!(props.width || props.height) && "size-full",
|
||||
"relative bg-stone-100 object-cover",
|
||||
"relative cursor-card bg-stone-100 object-cover",
|
||||
mediaContainerClassName,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function SwipeMedia({
|
|||
<div
|
||||
ref={hoverRef}
|
||||
className={cn(
|
||||
"relative flex w-full items-center overflow-hidden",
|
||||
"relative flex w-full items-center overflow-hidden ",
|
||||
styles["swipe-wrapper"],
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback } from "react"
|
||||
|
||||
import { PlainModal } from "../modal/stacked/custom-modal"
|
||||
import { useModalStack } from "../modal/stacked/hooks"
|
||||
import { NoopChildren } from "../modal/stacked/utils"
|
||||
import type { PreviewMediaProps } from "./preview-media"
|
||||
import { PreviewMediaContent } from "./preview-media"
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ export const usePreviewMedia = () => {
|
|||
blur: true,
|
||||
className: "bg-black/80",
|
||||
},
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
clickOutsideToDismiss: true,
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ export const PreviewMediaContent: FC<{
|
|||
<div
|
||||
tabIndex={-1}
|
||||
onClick={stopPropagation}
|
||||
className="center fixed bottom-4 left-1/2 h-6 gap-2 rounded-full bg-neutral-700/90 px-4 duration-200 animate-in fade-in-0 slide-in-from-bottom-6"
|
||||
className="center fixed bottom-4 left-1/2 h-6 -translate-x-1/2 gap-2 rounded-full bg-neutral-700/90 px-4 duration-200 animate-in fade-in-0 slide-in-from-bottom-6"
|
||||
>
|
||||
{Array.from({ length: media.length })
|
||||
.fill(0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { MotionButtonBase } from "../../button"
|
||||
import { useCurrentModal } from "./hooks"
|
||||
|
||||
export const ModalClose = () => {
|
||||
const { dismiss } = useCurrentModal()
|
||||
const { t } = useTranslation("common")
|
||||
|
||||
return (
|
||||
<MotionButtonBase
|
||||
aria-label={t("close")}
|
||||
className="absolute right-6 top-6 z-[99] flex size-8 items-center justify-center rounded-md duration-200 hover:bg-theme-button-hover"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<i className="i-mgc-close-cute-re block" />
|
||||
</MotionButtonBase>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { PropsWithChildren } from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { stopPropagation } from "~/lib/dom"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
import { ModalClose } from "./components"
|
||||
import { useCurrentModal } from "./hooks"
|
||||
|
||||
export const PlainModal = ({ children }: PropsWithChildren) => children
|
||||
|
||||
export { PlainModal as NoopChildren }
|
||||
|
||||
export const SlideUpModal = (props: PropsWithChildren) => {
|
||||
const winHeight = useState(() => window.innerHeight)[0]
|
||||
const { dismiss } = useCurrentModal()
|
||||
return (
|
||||
<div className={"container center h-full"} onPointerDown={dismiss} onClick={stopPropagation}>
|
||||
<m.div
|
||||
onPointerDown={stopPropagation}
|
||||
tabIndex={-1}
|
||||
initial={{
|
||||
y: "100%",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
y: winHeight,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
mass: 0.4,
|
||||
tension: 100,
|
||||
friction: 1,
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center overflow-hidden rounded-xl border bg-theme-background p-8 pb-0",
|
||||
"h-[80vh] w-[600px] max-w-full shadow lg:max-h-[calc(100vh-10rem)]",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
<ModalClose />
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -193,15 +193,13 @@ export const ModalInternal = memo(
|
|||
|
||||
const finalChildren = useMemo(
|
||||
() => (
|
||||
<CurrentModalContext.Provider value={ModalContextProps}>
|
||||
<AppErrorBoundary errorType={ErrorComponentType.Modal}>
|
||||
<RootPortalProvider value={edgeElementRef.current as HTMLElement}>
|
||||
{children ?? createElement(content, ModalProps)}
|
||||
</RootPortalProvider>
|
||||
</AppErrorBoundary>
|
||||
</CurrentModalContext.Provider>
|
||||
<AppErrorBoundary errorType={ErrorComponentType.Modal}>
|
||||
<RootPortalProvider value={edgeElementRef.current as HTMLElement}>
|
||||
{children ?? createElement(content, ModalProps)}
|
||||
</RootPortalProvider>
|
||||
</AppErrorBoundary>
|
||||
),
|
||||
[ModalContextProps, ModalProps, children, content],
|
||||
[ModalProps, children, content],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -281,7 +279,9 @@ export const ModalInternal = memo(
|
|||
onSelect={handleSelectStart}
|
||||
onKeyUp={handleDetectSelectEnd}
|
||||
>
|
||||
<CustomModalComponent>{finalChildren}</CustomModalComponent>
|
||||
<CurrentModalContext.Provider value={ModalContextProps}>
|
||||
<CustomModalComponent>{finalChildren}</CustomModalComponent>
|
||||
</CurrentModalContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
|
@ -373,7 +373,9 @@ export const ModalInternal = memo(
|
|||
<Divider className="my-2 shrink-0 border-slate-200 opacity-80 dark:border-neutral-800" />
|
||||
|
||||
<div className="min-h-0 shrink grow overflow-auto px-4 py-2">
|
||||
{finalChildren}
|
||||
<CurrentModalContext.Provider value={ModalContextProps}>
|
||||
{finalChildren}
|
||||
</CurrentModalContext.Provider>
|
||||
</div>
|
||||
</ResizeSwitch>
|
||||
</m.div>
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import type { PropsWithChildren } from "react"
|
||||
|
||||
export const NoopChildren = ({ children }: PropsWithChildren) => children
|
||||
|
|
@ -23,13 +23,13 @@ export const Radio: FC<
|
|||
onChange?.(e)
|
||||
})
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", wrapperClassName)}>
|
||||
<div className={cn("flex items-center", wrapperClassName)}>
|
||||
<input
|
||||
{...rest}
|
||||
type="radio"
|
||||
id={id ?? fallbackId}
|
||||
className={cn(
|
||||
"radio radio-accent radio-sm disabled:radio-current disabled:cursor-not-allowed disabled:text-theme-disabled",
|
||||
"radio radio-accent radio-sm accent-accent disabled:radio-current disabled:cursor-not-allowed disabled:text-theme-disabled",
|
||||
className,
|
||||
)}
|
||||
value={value}
|
||||
|
|
@ -37,7 +37,10 @@ export const Radio: FC<
|
|||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<label className={cn(rest.disabled ? "text-theme-disabled" : "")} htmlFor={id ?? fallbackId}>
|
||||
<label
|
||||
className={cn(rest.disabled ? "text-theme-disabled" : "", "pl-2")}
|
||||
htmlFor={id ?? fallbackId}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const SelectTrigger = React.forwardRef<
|
|||
"ring-accent/20 duration-200 focus:border-accent/80 focus:outline-none focus:ring-2",
|
||||
"border border-border",
|
||||
size === "sm" ? "h-7 p-2 text-sm" : "h-9 px-3 py-2 text-sm",
|
||||
"hover:border-accent/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -40,7 +41,7 @@ const SelectScrollUpButton = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
className={cn("flex cursor-select items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<i className="i-mingcute-up-line" />
|
||||
|
|
@ -54,7 +55,7 @@ const SelectScrollDownButton = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
className={cn("flex cursor-select items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<i className="i-mingcute-down-line" />
|
||||
|
|
@ -70,9 +71,11 @@ const SelectContent = React.forwardRef<
|
|||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"shadow-perfect relative z-[1000] max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"shadow-perfect relative z-[1000] max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground",
|
||||
position === "popper" && [
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
],
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
|
|
@ -113,7 +116,7 @@ const SelectItem = React.forwardRef<
|
|||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"pointer-events-auto relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-theme-item-active focus:text-theme-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"pointer-events-auto relative flex w-full cursor-select items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-theme-item-active focus:text-theme-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,17 @@ const Switch = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-switch items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"duration-200 data-[state=checked]:bg-accent data-[state=unchecked]:bg-theme-inactive dark:data-[state=unchecked]:bg-theme-accent-100/10",
|
||||
"hover:data-[state=unchecked]:bg-theme-inactive/80 dark:hover:data-[state=unchecked]:bg-theme-accent-100/15",
|
||||
"hover:data-[state=checked]:bg-accent/80 dark:hover:data-[state=checked]:bg-accent",
|
||||
"group",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb className="pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
|
||||
<SwitchPrimitives.Thumb className="pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform duration-200 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 group-hover:dark:data-[state=checked]:!bg-black/80" />
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { apiClient } from "~/lib/api-fetch"
|
|||
import { defineQuery } from "~/lib/defineQuery"
|
||||
import { nextFrame } from "~/lib/dom"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { useAchievementModal } from "~/modules/achievement/hooks"
|
||||
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
|
||||
import { usePresentUserProfileModal } from "~/modules/profile/hooks"
|
||||
import { useSettingModal } from "~/modules/settings/modal/hooks"
|
||||
|
|
@ -26,8 +27,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu/dropdown-menu"
|
||||
import { PlainModal } from "./ui/modal/stacked/custom-modal"
|
||||
import { useModalStack } from "./ui/modal/stacked/hooks"
|
||||
import { NoopChildren } from "./ui/modal/stacked/utils"
|
||||
|
||||
interface LoginProps {
|
||||
method?: "redirect" | "modal"
|
||||
|
|
@ -43,7 +44,7 @@ export const LoginButton: FC<LoginProps> = (props) => {
|
|||
method === "modal"
|
||||
? () => {
|
||||
modalStack.present({
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
title: "Login",
|
||||
id: "login",
|
||||
content: () => <LoginModalContent runtime={window.electron ? "app" : "browser"} />,
|
||||
|
|
@ -65,6 +66,7 @@ export const ProfileButton: FC<LoginProps> = memo((props) => {
|
|||
const signOut = useSignOut()
|
||||
const settingModalPresent = useSettingModal()
|
||||
const presentUserProfile = usePresentUserProfileModal("dialog")
|
||||
const presentAchievement = useAchievementModal()
|
||||
const { t } = useTranslation()
|
||||
if (status !== "authenticated") {
|
||||
return <LoginButton {...props} />
|
||||
|
|
@ -99,6 +101,15 @@ export const ProfileButton: FC<LoginProps> = memo((props) => {
|
|||
>
|
||||
{t("user_button.profile")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
presentAchievement()
|
||||
}}
|
||||
icon={<i className="i-mgc-trophy-cute-re" />}
|
||||
>
|
||||
{t("user_button.achievement")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
|
|
@ -175,6 +186,7 @@ export function UserAvatar({
|
|||
return <LoginButton {...props} />
|
||||
}
|
||||
|
||||
const renderUserData = userId ? profile.data : session?.user
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -191,11 +203,11 @@ export function UserAvatar({
|
|||
>
|
||||
<AvatarImage
|
||||
className="duration-200 animate-in fade-in-0"
|
||||
src={(profile.data || session?.user)?.image || undefined}
|
||||
src={renderUserData?.image || undefined}
|
||||
/>
|
||||
<AvatarFallback>{(profile.data || session?.user)?.name?.slice(0, 2)}</AvatarFallback>
|
||||
<AvatarFallback>{renderUserData?.name?.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{!hideName && <div>{(profile.data || session?.user)?.name}</div>}
|
||||
{!hideName && <div>{renderUserData?.name}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const shortcuts = {
|
|||
layout: {
|
||||
toggleSidebar: {
|
||||
name: "keys.layout.toggleSidebar",
|
||||
key: "Meta+B",
|
||||
key: "Meta+B, [",
|
||||
},
|
||||
showShortcuts: {
|
||||
name: "keys.layout.showShortcuts",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { FeedModel } from "~/models"
|
||||
import type { TargetModel } from "~/models"
|
||||
|
||||
export type DB_FeedUnread = {
|
||||
id: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type DB_Feed = FeedModel & { id: string }
|
||||
export type DB_Feed = TargetModel & { id: string }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const useFeedActions = ({
|
|||
|
||||
const items = useMemo(() => {
|
||||
if (!feed) return []
|
||||
const isList = feed?.type === "list"
|
||||
const items: NativeMenuItem[] = [
|
||||
{
|
||||
type: "text" as const,
|
||||
|
|
@ -46,7 +47,9 @@ export const useFeedActions = ({
|
|||
click: () => {
|
||||
present({
|
||||
title: t("sidebar.feed_actions.edit_feed"),
|
||||
content: ({ dismiss }) => <FeedForm asWidget id={feedId} onSuccess={dismiss} />,
|
||||
content: ({ dismiss }) => (
|
||||
<FeedForm asWidget id={feedId} onSuccess={dismiss} isList={isList} />
|
||||
),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
|
@ -60,7 +63,11 @@ export const useFeedActions = ({
|
|||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.navigate_to_feed"),
|
||||
label: t(
|
||||
isList
|
||||
? "sidebar.feed_actions.navigate_to_list"
|
||||
: "sidebar.feed_actions.navigate_to_feed",
|
||||
),
|
||||
shortcut: "Meta+G",
|
||||
disabled: !isEntryList || getRouteParams().feedId === feedId,
|
||||
click: () => {
|
||||
|
|
@ -71,14 +78,18 @@ export const useFeedActions = ({
|
|||
type: "separator" as const,
|
||||
disabled: isEntryList,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: t("sidebar.feed_actions.mark_all_as_read"),
|
||||
shortcut: "Meta+Shift+A",
|
||||
disabled: isEntryList,
|
||||
click: () => subscriptionActions.markReadByFeedIds([feedId]),
|
||||
},
|
||||
...(!feed.ownerUserId && !!feed.id
|
||||
...(!isList
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.mark_all_as_read"),
|
||||
shortcut: "Meta+Shift+A",
|
||||
disabled: isEntryList,
|
||||
click: () => subscriptionActions.markReadByFeedIds({ feedIds: [feedId] }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!feed.ownerUserId && !!feed.id && !isList
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
|
|
@ -96,7 +107,11 @@ export const useFeedActions = ({
|
|||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.feed_owned_by_you"),
|
||||
label: t(
|
||||
isList
|
||||
? "sidebar.feed_actions.list_owned_by_you"
|
||||
: "sidebar.feed_actions.feed_owned_by_you",
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -106,37 +121,58 @@ export const useFeedActions = ({
|
|||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.open_feed_in_browser"),
|
||||
label: t(
|
||||
isList
|
||||
? "sidebar.feed_actions.open_list_in_browser"
|
||||
: "sidebar.feed_actions.open_feed_in_browser",
|
||||
),
|
||||
disabled: isEntryList,
|
||||
shortcut: "O",
|
||||
click: () => window.open(`${WEB_URL}/feed/${feedId}?view=${view}`, "_blank"),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.open_site_in_browser"),
|
||||
shortcut: "Meta+O",
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
const feed = getFeedById(feedId)
|
||||
if (feed) {
|
||||
feed.siteUrl && window.open(feed.siteUrl, "_blank")
|
||||
}
|
||||
},
|
||||
click: () =>
|
||||
window.open(
|
||||
isList
|
||||
? `${WEB_URL}/list/${feedId}?view=${view}`
|
||||
: `${WEB_URL}/feed/${feedId}?view=${view}`,
|
||||
"_blank",
|
||||
),
|
||||
},
|
||||
...(!isList
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.open_site_in_browser"),
|
||||
shortcut: "Meta+O",
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
const feed = getFeedById(feedId)
|
||||
if (feed) {
|
||||
"siteUrl" in feed && feed.siteUrl && window.open(feed.siteUrl, "_blank")
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "separator",
|
||||
disabled: isEntryList,
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.copy_feed_url"),
|
||||
label: t(
|
||||
isList ? "sidebar.feed_actions.copy_list_url" : "sidebar.feed_actions.copy_feed_url",
|
||||
),
|
||||
disabled: isEntryList,
|
||||
shortcut: "Meta+C",
|
||||
click: () => navigator.clipboard.writeText(feed.url),
|
||||
click: () => {
|
||||
const url = isList ? `${WEB_URL}/list/${feedId}?view=${view}` : feed.url
|
||||
navigator.clipboard.writeText(url)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.copy_feed_id"),
|
||||
label: t(
|
||||
isList ? "sidebar.feed_actions.copy_list_id" : "sidebar.feed_actions.copy_feed_id",
|
||||
),
|
||||
shortcut: "Meta+Shift+C",
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void })
|
|||
// TODO store action
|
||||
await apiClient.subscriptions.$post({
|
||||
json: {
|
||||
url: feed.url,
|
||||
url: feed.type === "feed" ? feed.url : undefined,
|
||||
listId: feed.type === "list" ? feed.id : undefined,
|
||||
view: subscription.view,
|
||||
category: subscription.category,
|
||||
isPrivate: subscription.isPrivate,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import duration from "dayjs/plugin/duration"
|
|||
import localizedFormat from "dayjs/plugin/localizedFormat"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import { enableMapSet } from "immer"
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getUISettings } from "~/atoms/settings/ui"
|
||||
|
|
@ -118,6 +120,10 @@ export const initializeApp = async () => {
|
|||
data_hydrated_time: dataHydratedTime,
|
||||
version: APP_VERSION,
|
||||
})
|
||||
|
||||
// expose `React` `ReactDOM` to global, it's easier for developers to make plugins
|
||||
window.React = React
|
||||
window.ReactDOM = ReactDOM
|
||||
}
|
||||
|
||||
import.meta.hot?.dispose(cleanup)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
import { sleep } from "./utils"
|
||||
|
||||
export class Chain {
|
||||
private chain: Promise<void> = Promise.resolve()
|
||||
private abortController = new AbortController()
|
||||
|
||||
next(fn: () => any | Promise<any>) {
|
||||
const { signal } = this.abortController
|
||||
this.chain = this.chain.then(() => {
|
||||
if (signal.aborted) {
|
||||
throw "Chain aborted"
|
||||
}
|
||||
return fn() || Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
wait(ms: number): this {
|
||||
this.chain = this.chain.then(() => sleep(ms))
|
||||
return this
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.chain = Promise.resolve()
|
||||
this.abortController.abort()
|
||||
this.abortController = new AbortController()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ const getRandomColor = (lightness: [number, number], saturation: [number, number
|
|||
const lightBackground = lightAccent < 80 ? lightAccent + 20 : 100
|
||||
|
||||
return {
|
||||
accent: `hsl(${hue}, ${satAccent}%, ${lightAccent}%)`,
|
||||
background: `hsl(${hue}, ${satBackground}%, ${lightBackground}%)`,
|
||||
accent: `hsl(${hue} ${satAccent}% ${lightAccent}%)`,
|
||||
background: `hsl(${hue} ${satBackground}% ${lightBackground}%)`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const parseSocialMedia = (entry: EntryModel) => {
|
|||
const { authorUrl, url, guid } = entry
|
||||
|
||||
const parsedUrl = authorUrl || url || guid
|
||||
const isX = isXUrl(parsedUrl) || isTwitterUrl(parsedUrl)
|
||||
const isX = isXUrl(parsedUrl).validate || isTwitterUrl(parsedUrl).validate
|
||||
|
||||
if (isX) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
|
||||
import { isBizId } from "./utils"
|
||||
|
||||
describe("utils", () => {
|
||||
test("isBizId", () => {
|
||||
expect(isBizId("1712546615000")).toBe(true)
|
||||
expect(isBizId("17125466150000")).toBe(true)
|
||||
expect(isBizId("171254661500000")).toBe(true)
|
||||
expect(isBizId("1712546615000000")).toBe(true)
|
||||
expect(isBizId("17125466150000000")).toBe(true)
|
||||
expect(isBizId("171254661500000000")).toBe(true)
|
||||
expect(isBizId("1712546615000000000")).toBe(true)
|
||||
expect(isBizId("9994780527253199722")).toBe(false)
|
||||
|
||||
// sample biz id
|
||||
expect(isBizId("41147805272531997")).toBe(true)
|
||||
|
||||
// test string
|
||||
expect(isBizId("ep 1712546615000")).toBe(false)
|
||||
expect(isBizId("又有人在微博提到 DIYgod 了")).toBe(false)
|
||||
|
||||
// test short number
|
||||
expect(isBizId("123456789")).toBe(false)
|
||||
|
||||
// test long number
|
||||
expect(isBizId("12345678901234567890")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -27,7 +27,11 @@ export function getEntriesParams({ id, view }: { id?: number | string; view?: nu
|
|||
if (id === FEED_COLLECTION_LIST) {
|
||||
params.isCollection = true
|
||||
} else if (id && id !== ROUTE_FEED_PENDING) {
|
||||
params.feedIdList = `${id}`.split(",")
|
||||
if (id.toString().includes(",")) {
|
||||
params.feedIdList = `${id}`.split(",")
|
||||
} else {
|
||||
params.feedId = `${id}`
|
||||
}
|
||||
}
|
||||
if (view === FeedViewType.SocialMedia) {
|
||||
params.withContent = true
|
||||
|
|
@ -104,13 +108,25 @@ export const isSafari = memoize(() => {
|
|||
// eslint-disable-next-line no-control-regex
|
||||
export const isASCII = (str) => /^[\u0000-\u007F]*$/.test(str)
|
||||
|
||||
export const isBizId = (id: string) => {
|
||||
if (!id) return false
|
||||
const EPOCH = 1712546615000n // follow repo created
|
||||
const MAX_TIMESTAMP_BITS = 41n // Maximum number of bits typically used for timestamp
|
||||
|
||||
// 1. check is snowflake
|
||||
// snowflake ep 1712546615000
|
||||
if (id.length > 13 && id.length < 20 && !Number.isNaN(id)) {
|
||||
return true
|
||||
export const isBizId = (id: string): boolean => {
|
||||
if (!id || !/^\d{13,19}$/.test(id)) return false
|
||||
|
||||
const snowflake = BigInt(id)
|
||||
|
||||
// Extract the timestamp assuming it's in the most significant bits after the sign bit
|
||||
const timestamp = (snowflake >> (63n - MAX_TIMESTAMP_BITS)) + EPOCH
|
||||
const date = new Date(Number(timestamp))
|
||||
|
||||
// Check if the date is reasonable (between 2024 and 2050)
|
||||
if (date.getFullYear() >= 2024 && date.getFullYear() <= 2050) {
|
||||
// Additional validation: check if the ID is not larger than the maximum possible value
|
||||
const maxPossibleId = (1n << 63n) - 1n // Maximum possible 63-bit value
|
||||
if (snowflake <= maxPossibleId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -19,22 +19,15 @@ export type ActiveList = {
|
|||
view: number
|
||||
}
|
||||
|
||||
export type FeedResponse = SubscriptionResponse[number]["feeds"]
|
||||
|
||||
export type TransactionModel = ExtractBizResponse<
|
||||
typeof apiClient.wallets.transactions.$get
|
||||
>["data"][number]
|
||||
|
||||
export type FeedModel = ExtractBizResponse<typeof apiClient.feeds.$get>["data"]["feed"] & {
|
||||
owner?: UserModel | null
|
||||
tipUsers?: UserModel[] | null
|
||||
}
|
||||
export type FeedModel = ExtractBizResponse<typeof apiClient.feeds.$get>["data"]["feed"]
|
||||
|
||||
export type SubscriptionResponse = Array<
|
||||
ExtractBizResponse<typeof apiClient.subscriptions.$get>["data"][number] & {
|
||||
unread?: number
|
||||
}
|
||||
>
|
||||
export type ListModel = ExtractBizResponse<typeof apiClient.lists.$get>["data"]["list"]
|
||||
|
||||
export type TargetModel = FeedModel | ListModel
|
||||
|
||||
export type EntryResponse = Exclude<
|
||||
Extract<ExtractBizResponse<typeof apiClient.entries.$get>, { code: 0 }>["data"],
|
||||
|
|
@ -60,13 +53,6 @@ export type ActionsResponse = Exclude<
|
|||
undefined
|
||||
>["rules"]
|
||||
|
||||
export type ListResponse<T> = {
|
||||
code: number
|
||||
data?: T
|
||||
total?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type DataResponse<T> = {
|
||||
code: number
|
||||
data?: T
|
||||
|
|
@ -74,15 +60,15 @@ export type DataResponse<T> = {
|
|||
|
||||
export type ActiveEntryId = Nullable<string>
|
||||
|
||||
export type SubscriptionModel = SubscriptionResponse[number]
|
||||
|
||||
export type FeedListModel = {
|
||||
list: {
|
||||
list: SubscriptionResponse
|
||||
name: string
|
||||
}[]
|
||||
export type SubscriptionModel = ExtractBizResponse<
|
||||
typeof apiClient.subscriptions.$get
|
||||
>["data"][number] & {
|
||||
unread?: number
|
||||
}
|
||||
|
||||
export type FeedSubscriptionModel = Extract<SubscriptionModel, { feeds: any }>
|
||||
export type ListSubscriptionModel = Extract<SubscriptionModel, { list: any }>
|
||||
|
||||
export type SupportedLanguages = z.infer<typeof languageSchema>
|
||||
|
||||
export type RecommendationItem = ExtractBizResponse<
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
import { DotLottieReact } from "@lottiefiles/dotlottie-react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useSingleton } from "foxact/use-singleton"
|
||||
import { atom, useStore } from "jotai"
|
||||
import { nanoid } from "nanoid"
|
||||
import type { FC } from "react"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { Trans } from "react-i18next"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { styledButtonVariant } from "~/components/ui/button/variants"
|
||||
import { LoadingCircle, LoadingWithIcon } from "~/components/ui/loading"
|
||||
import { useModalStack } from "~/components/ui/modal"
|
||||
import { SlideUpModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { useI18n } from "~/hooks/common"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { Chain } from "~/lib/chain"
|
||||
import achievementAnimationUri from "~/lottie/achievement.lottie?url"
|
||||
|
||||
const absoluteachievementAnimationUri = new URL(achievementAnimationUri, import.meta.url).href
|
||||
|
||||
enum AchievementsActionIdMap {
|
||||
FIRST_CLAIM_FEED = 0,
|
||||
}
|
||||
|
||||
const achievementActionIdCopyMap: Record<
|
||||
AchievementsActionIdMap,
|
||||
{ title: I18nKeys; description: I18nKeys }
|
||||
> = {
|
||||
[AchievementsActionIdMap.FIRST_CLAIM_FEED]: {
|
||||
title: "achievement.first_claim_feed",
|
||||
description: "achievement.first_claim_feed_description",
|
||||
},
|
||||
}
|
||||
|
||||
export const useAchievementModal = () => {
|
||||
const { present } = useModalStack()
|
||||
|
||||
return useCallback(() => {
|
||||
present({
|
||||
id: "achievement",
|
||||
title: "Achievements",
|
||||
content: AchievementModalContent,
|
||||
CustomModalComponent: SlideUpModal,
|
||||
})
|
||||
}, [present])
|
||||
}
|
||||
|
||||
const buildDefaultAchievements = () => {
|
||||
return [
|
||||
{
|
||||
id: nanoid(),
|
||||
actionId: AchievementsActionIdMap.FIRST_CLAIM_FEED,
|
||||
type: "checking",
|
||||
progress: 0,
|
||||
progressMax: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
export const AchievementModalContent: FC = () => {
|
||||
const jotaiStore = useStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const defaultAchievements = useSingleton(buildDefaultAchievements)
|
||||
|
||||
const _achievementType = apiClient.achievement.$get({
|
||||
query: {
|
||||
type: "all",
|
||||
},
|
||||
})
|
||||
type Achievement = Awaited<typeof _achievementType>["data"]
|
||||
const {
|
||||
data: achievements,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["achievements"],
|
||||
meta: {
|
||||
persist: true,
|
||||
},
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.achievement.$get({
|
||||
query: {
|
||||
type: "all",
|
||||
},
|
||||
})
|
||||
|
||||
jotaiStore.set(achievementsDataAtom.current, res.data)
|
||||
return res.data
|
||||
},
|
||||
initialData: defaultAchievements.current as Achievement,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!achievements) return
|
||||
let shouldPolling = false
|
||||
const pollingChain = new Chain()
|
||||
for (const achievement of achievements) {
|
||||
if (achievement.type === "checking") {
|
||||
shouldPolling = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPolling) {
|
||||
pollingChain.wait(2000).next(() => {
|
||||
refetch()
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
pollingChain.abort()
|
||||
}
|
||||
}, [achievements, refetch])
|
||||
|
||||
const { mutateAsync: mintAchievement, isPending: isMinting } = useMutation({
|
||||
mutationFn: async (actionId: number) => {
|
||||
return apiClient.achievement.$put({
|
||||
json: {
|
||||
actionId,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const achievementsDataAtom = useSingleton(() => atom<typeof achievements>())
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full grow flex-col items-center">
|
||||
<DotLottieReact
|
||||
className="mt-12 size-[100px]"
|
||||
autoplay
|
||||
speed={2}
|
||||
src={absoluteachievementAnimationUri}
|
||||
loop={false}
|
||||
/>
|
||||
|
||||
<div className="mt-4 text-xl font-bold">{t("words.achievement")}</div>
|
||||
|
||||
<small className="center mt-1 gap-1 text-theme-vibrancyFg">
|
||||
<Trans
|
||||
i18nKey={"achievement.mint_more_power"}
|
||||
components={{
|
||||
power: (
|
||||
<span className="center gap-0.5 font-semibold">
|
||||
<span>{t("words.power")}</span>
|
||||
<i className="i-mgc-power text-accent" />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
|
||||
<ul className="mt-10 flex w-full grow flex-col gap-2">
|
||||
{isLoading ? (
|
||||
<div className="center pointer-events-none grow -translate-y-16">
|
||||
<LoadingWithIcon icon={<i className="i-mgc-trophy-cute-re" />} size="large" />
|
||||
</div>
|
||||
) : (
|
||||
achievements?.map((achievement) => {
|
||||
const copy = achievementActionIdCopyMap[achievement.actionId]
|
||||
if (!copy) return null
|
||||
|
||||
return (
|
||||
<li key={achievement.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-base font-bold">
|
||||
{t(copy.title)}
|
||||
|
||||
{achievement.power && (
|
||||
<span className="ml-2 inline-flex items-center gap-0.5 text-xs font-normal">
|
||||
<span className="font-medium opacity-80">{achievement.power}</span>
|
||||
<i className="i-mgc-power text-sm text-accent" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
{t(copy.description)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{achievement.type === "checking" && (
|
||||
<div
|
||||
className={styledButtonVariant({
|
||||
variant: "outline",
|
||||
className: "relative border-0 pointer-events-none",
|
||||
})}
|
||||
>
|
||||
<LoadingCircle size="small" className="center absolute inset-0" />
|
||||
<span className="select-none opacity-0">{t("words.mint")}</span>
|
||||
</div>
|
||||
)}
|
||||
{achievement.type === "incomplete" && (
|
||||
<div
|
||||
className={styledButtonVariant({
|
||||
variant: "ghost",
|
||||
className: "relative hover:bg-transparent",
|
||||
})}
|
||||
>
|
||||
<span className="center relative ml-2 inline-flex w-24 -translate-y-1 flex-col *:!m-0">
|
||||
<small className="shrink-0 text-xs leading-tight text-muted-foreground">
|
||||
{achievement.progress} / {achievement.progressMax}
|
||||
</small>
|
||||
<span className="relative h-1 w-full overflow-hidden rounded-full bg-accent/10">
|
||||
<span
|
||||
className="absolute -left-3 top-0 inline-block h-1 rounded-full bg-accent"
|
||||
style={{
|
||||
width: `calc(${Math.min(
|
||||
(achievement.progress / achievement.progressMax) * 100,
|
||||
100,
|
||||
)}% + 0.75rem)`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{achievement.type === "received" && (
|
||||
<div
|
||||
className={styledButtonVariant({
|
||||
variant: "outline",
|
||||
className:
|
||||
"relative !bg-green-100/50 border-green-200 text-green-800 dark:text-foreground dark:!bg-green-100/5 dark:border-green-200/20",
|
||||
})}
|
||||
>
|
||||
<i className="i-mgc-check-filled" />
|
||||
{t("achievement.all_done")}
|
||||
</div>
|
||||
)}
|
||||
{achievement.type === "completed" && (
|
||||
<Button
|
||||
isLoading={isMinting}
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
const res = await mintAchievement(achievement.actionId)
|
||||
|
||||
const currentData = jotaiStore.get(achievementsDataAtom.current)
|
||||
if (!currentData) return
|
||||
let shouldInvalidate = false
|
||||
const newData = currentData.map((item) => {
|
||||
if (item.id === achievement.id && res.data.result) {
|
||||
shouldInvalidate = true
|
||||
return {
|
||||
...item,
|
||||
done: true,
|
||||
doneAt: new Date().toISOString(),
|
||||
type: "received",
|
||||
} as const
|
||||
}
|
||||
return item
|
||||
})
|
||||
jotaiStore.set(achievementsDataAtom.current, newData)
|
||||
|
||||
if (shouldInvalidate) {
|
||||
queryClient.invalidateQueries({ queryKey: ["achievements"] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("words.mint")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import { Media } from "~/components/ui/media"
|
|||
import { usePreviewMedia } from "~/components/ui/media/hooks"
|
||||
import { useModalStack } from "~/components/ui/modal"
|
||||
import { PeekModal } from "~/components/ui/modal/inspire/PeekModal"
|
||||
import { NoopChildren } from "~/components/ui/modal/stacked/utils"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { Paper } from "~/components/ui/paper"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
|
|
@ -65,7 +65,7 @@ export const DailyReportTitle = ({
|
|||
<div className="font-medium">{t("ai_daily.title", { title })}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<i className="i-mgc-question-cute-re translate-y-px text-sm" />
|
||||
<i className="i-mgc-question-cute-re text-sm" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
|
|
@ -242,7 +242,7 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps
|
|||
if (variant === "toast") {
|
||||
present({
|
||||
...basePresentProps,
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
content: () => <EntryToastPreview entryId={entryId} />,
|
||||
overlay: false,
|
||||
modal: false,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { m } from "framer-motion"
|
||||
import clsx from "clsx"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { FollowIcon } from "~/components/icons/follow"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { MotionButtonBase } from "~/components/ui/button"
|
||||
import { LoadingCircle } from "~/components/ui/loading"
|
||||
import { useCurrentModal } from "~/components/ui/modal"
|
||||
import { modalMontionConfig } from "~/components/ui/modal/stacked/constants"
|
||||
import type { LoginRuntime } from "~/lib/auth"
|
||||
|
|
@ -20,6 +23,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [loadingLockSet, setLoadingLockSet] = useState<false | string>(false)
|
||||
|
||||
return (
|
||||
<div className="center flex h-full" onClick={canClose ? modal.dismiss : undefined}>
|
||||
<m.div
|
||||
|
|
@ -35,24 +40,79 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
className="h-[48px] w-[320px] rounded-[8px] !bg-black font-sans text-base text-white hover:!bg-black/80 focus:!border-black/80 focus:!ring-black/80"
|
||||
<MotionButtonBase
|
||||
className={clsx(
|
||||
"center h-[48px] w-[320px] rounded-[8px] !bg-black font-sans text-base font-medium text-white hover:!bg-black/80 focus:!border-black/80 focus:!ring-black/80",
|
||||
!!loadingLockSet && "pointer-events-none opacity-50",
|
||||
)}
|
||||
disabled={!!loadingLockSet}
|
||||
onClick={() => {
|
||||
loginHandler("github", runtime)
|
||||
setLoadingLockSet("github")
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-github-cute-fi mr-2 text-xl" /> {t("signin.continue_with_github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-[48px] w-[320px] rounded-[8px] bg-blue-500 font-sans text-base text-white hover:bg-blue-500/90 focus:!border-blue-500/80 focus:!ring-blue-500/80"
|
||||
<LoginButtonContent isLoading={loadingLockSet === "github"}>
|
||||
<i className="i-mgc-github-cute-fi mr-2 text-xl" />
|
||||
{t("signin.continue_with_github")}
|
||||
</LoginButtonContent>
|
||||
</MotionButtonBase>
|
||||
<MotionButtonBase
|
||||
disabled={!!loadingLockSet}
|
||||
className={clsx(
|
||||
"center h-[48px] w-[320px] rounded-[8px] bg-blue-500 font-sans text-base font-medium text-white hover:bg-blue-500/90 focus:!border-blue-500/80 focus:!ring-blue-500/80",
|
||||
!!loadingLockSet && "pointer-events-none opacity-50",
|
||||
"overflow-hidden",
|
||||
)}
|
||||
onClick={() => {
|
||||
loginHandler("google", runtime)
|
||||
setLoadingLockSet("google")
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-google-cute-fi mr-2 text-xl" /> {t("signin.continue_with_google")}
|
||||
</Button>
|
||||
<LoginButtonContent isLoading={loadingLockSet === "google"}>
|
||||
<i className="i-mgc-google-cute-fi mr-2 text-xl" />
|
||||
{t("signin.continue_with_google")}
|
||||
</LoginButtonContent>
|
||||
</MotionButtonBase>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoginButtonContent = (props: { children: React.ReactNode; isLoading: boolean }) => {
|
||||
const { children, isLoading } = props
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isLoading ? (
|
||||
<m.div
|
||||
animate={{
|
||||
y: 0,
|
||||
}}
|
||||
initial={{
|
||||
y: -30,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
}}
|
||||
key="loading"
|
||||
className="contents"
|
||||
>
|
||||
<LoadingCircle size={"medium"} />
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div
|
||||
key="text"
|
||||
className="center"
|
||||
transition={{
|
||||
type: "spring",
|
||||
}}
|
||||
exit={{
|
||||
y: "-100%",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { LoadingCircle } from "~/components/ui/loading"
|
|||
import { useCurrentModal } from "~/components/ui/modal"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import type { FeedModel } from "~/models"
|
||||
import { Queries } from "~/queries"
|
||||
import { useClaimFeedMutation } from "~/queries/feed"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
|
|
@ -20,7 +21,7 @@ export const FeedClaimModalContent: FC<{
|
|||
feedId: string
|
||||
}> = ({ feedId }) => {
|
||||
const { t } = useTranslation()
|
||||
const feed = useFeedById(feedId)
|
||||
const feed = useFeedById(feedId) as FeedModel
|
||||
const {
|
||||
data: claimMessage,
|
||||
isLoading,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import { Input } from "~/components/ui/input"
|
|||
import { LoadingCircle } from "~/components/ui/loading"
|
||||
import { useCurrentModal } from "~/components/ui/modal"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import { views } from "~/constants"
|
||||
import { useAuthQuery, useI18n } from "~/hooks/common"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
|
|
@ -38,6 +37,8 @@ import { useFeedByIdOrUrl } from "~/store/feed"
|
|||
import { useSubscriptionByFeedId } from "~/store/subscription"
|
||||
import { feedUnreadActions } from "~/store/unread"
|
||||
|
||||
import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup"
|
||||
|
||||
const formSchema = z.object({
|
||||
view: z.string(),
|
||||
category: z.string().nullable().optional(),
|
||||
|
|
@ -49,14 +50,15 @@ const defaultValue = { view: FeedViewType.Articles.toString() } as z.infer<typeo
|
|||
export const FeedForm: Component<{
|
||||
url?: string
|
||||
id?: string
|
||||
isList?: boolean
|
||||
|
||||
defaultValues?: z.infer<typeof formSchema>
|
||||
|
||||
asWidget?: boolean
|
||||
|
||||
onSuccess?: () => void
|
||||
}> = ({ id: _id, defaultValues = defaultValue, url, asWidget, onSuccess }) => {
|
||||
const queryParams = { id: _id, url }
|
||||
}> = ({ id: _id, defaultValues = defaultValue, url, asWidget, onSuccess, isList }) => {
|
||||
const queryParams = { id: _id, url, isList }
|
||||
|
||||
const feedQuery = useFeed(queryParams)
|
||||
|
||||
|
|
@ -165,10 +167,16 @@ const FeedInnerForm = ({
|
|||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const isSubscribed = !!subscription
|
||||
const feed = useFeedByIdOrUrl({ id, url })!
|
||||
const isList = feed?.type === "list"
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues,
|
||||
defaultValues: isList
|
||||
? {
|
||||
...defaultValues,
|
||||
view: feed.view.toString(),
|
||||
}
|
||||
: defaultValues,
|
||||
})
|
||||
|
||||
const { setClickOutSideToDismiss, dismiss } = useCurrentModal()
|
||||
|
|
@ -189,17 +197,16 @@ const FeedInnerForm = ({
|
|||
const followMutation = useMutation({
|
||||
mutationFn: async (values: z.infer<typeof formSchema>) => {
|
||||
const body = {
|
||||
url: feed.url,
|
||||
...(isList ? { listId: feed.id } : { url: feed.url }),
|
||||
view: Number.parseInt(values.view),
|
||||
category: values.category,
|
||||
isPrivate: values.isPrivate,
|
||||
title: values.title,
|
||||
...(isSubscribed && { feedId: feed.id }),
|
||||
...(isSubscribed && !isList && { feedId: feed.id }),
|
||||
}
|
||||
const $method = isSubscribed ? apiClient.subscriptions.$patch : apiClient.subscriptions.$post
|
||||
|
||||
return $method({
|
||||
// @ts-expect-error
|
||||
json: body,
|
||||
})
|
||||
},
|
||||
|
|
@ -265,35 +272,12 @@ const FeedInnerForm = ({
|
|||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("feed_form.view")}</FormLabel>
|
||||
<Card>
|
||||
<CardHeader className="grid grid-cols-6 space-y-0 px-2 py-3">
|
||||
{views.map((view) => (
|
||||
<div key={view.name}>
|
||||
<input
|
||||
className="peer hidden"
|
||||
type="radio"
|
||||
id={view.name}
|
||||
value={view.view}
|
||||
{...form.register("view")}
|
||||
/>
|
||||
<label
|
||||
htmlFor={view.name}
|
||||
className={cn(
|
||||
"hover:text-theme-foreground dark:hover:text-white",
|
||||
view.peerClassName,
|
||||
"center flex h-10 flex-col text-xs leading-none opacity-80 duration-200",
|
||||
"text-neutral-800 dark:text-zinc-200",
|
||||
"peer-checked:opacity-100",
|
||||
"whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{view.icon}</span>
|
||||
{t(view.name)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<ViewSelectorRadioGroup
|
||||
{...form.register("view")}
|
||||
disabled={isList}
|
||||
className={cn(isList && "opacity-60")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -314,34 +298,36 @@ const FeedInnerForm = ({
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div>
|
||||
<FormLabel>{t("feed_form.category")}</FormLabel>
|
||||
<FormDescription>{t("feed_form.category_description")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
{!isList && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div>
|
||||
<Autocomplete
|
||||
maxHeight={window.innerHeight < 600 ? 120 : 240}
|
||||
portal
|
||||
suggestions={suggestions}
|
||||
{...(field as any)}
|
||||
onSuggestionSelected={(suggestion) => {
|
||||
if (suggestion) {
|
||||
field.onChange(suggestion.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormLabel>{t("feed_form.category")}</FormLabel>
|
||||
<FormDescription>{t("feed_form.category_description")}</FormDescription>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Autocomplete
|
||||
maxHeight={window.innerHeight < 600 ? 120 : 240}
|
||||
portal
|
||||
suggestions={suggestions}
|
||||
{...(field as any)}
|
||||
onSuggestionSelected={(suggestion) => {
|
||||
if (suggestion) {
|
||||
field.onChange(suggestion.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isPrivate"
|
||||
|
|
@ -363,7 +349,18 @@ const FeedInnerForm = ({
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isList && !!feed.fee && !isSubscribed && (
|
||||
<div>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{t("feed_form.fee")}{" "}
|
||||
<div className="ml-2 flex scale-[0.85] items-center gap-1">
|
||||
{feed.fee}
|
||||
<i className="i-mgc-power size-4 text-accent" />
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormDescription className="mt-0.5">{t("feed_form.fee_description")}</FormDescription>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 items-end justify-end gap-4">
|
||||
{isSubscribed && (
|
||||
<Button
|
||||
|
|
@ -378,7 +375,13 @@ const FeedInnerForm = ({
|
|||
</Button>
|
||||
)}
|
||||
<Button ref={buttonRef} type="submit" isLoading={followMutation.isPending}>
|
||||
{isSubscribed ? t("feed_form.update") : t("feed_form.follow")}
|
||||
{isSubscribed
|
||||
? t("feed_form.update")
|
||||
: isList && feed.fee
|
||||
? t("feed_form.follow_with_fee", {
|
||||
fee: feed.fee,
|
||||
})
|
||||
: t("feed_form.follow")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
import { Input } from "~/components/ui/input"
|
||||
import { Media } from "~/components/ui/media"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { Radio } from "~/components/ui/radio-group"
|
||||
import { RadioGroup } from "~/components/ui/radio-group/RadioGroup"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import type { FeedViewType } from "~/lib/enum"
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ import { FeedForm } from "./feed-form"
|
|||
|
||||
const formSchema = z.object({
|
||||
keyword: z.string().min(1),
|
||||
target: z.enum(["feeds", "lists"]),
|
||||
})
|
||||
|
||||
const info: Record<
|
||||
|
|
@ -66,16 +69,18 @@ export function DiscoverForm({ type }: { type: string }) {
|
|||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
keyword: defaultValue || "",
|
||||
target: "feeds",
|
||||
},
|
||||
})
|
||||
const { t } = useTranslation()
|
||||
|
||||
const jotaiStore = useStore()
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (keyword: string) => {
|
||||
mutationFn: async ({ keyword, target }: { keyword: string; target: "feeds" | "lists" }) => {
|
||||
const { data } = await apiClient.discover.$post({
|
||||
json: {
|
||||
keyword,
|
||||
target,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -107,7 +112,7 @@ export function DiscoverForm({ type }: { type: string }) {
|
|||
),
|
||||
})
|
||||
} else {
|
||||
mutation.mutate(values.keyword)
|
||||
mutation.mutate(values)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +144,9 @@ export function DiscoverForm({ type }: { type: string }) {
|
|||
jotaiStore.set(
|
||||
discoverSearchDataAtom,
|
||||
produce(currentData, (draft) => {
|
||||
const sub = draft.find((i) => i.feed.id === item.feed.id)
|
||||
const sub = draft.find(
|
||||
(i) => i.feed?.id === item.feed?.id || i.list?.id === item.list?.id,
|
||||
)
|
||||
if (!sub) return
|
||||
sub.isSubscribed = true
|
||||
sub.subscriptionCount = -~(sub.subscriptionCount as number)
|
||||
|
|
@ -156,7 +163,9 @@ export function DiscoverForm({ type }: { type: string }) {
|
|||
jotaiStore.set(
|
||||
discoverSearchDataAtom,
|
||||
produce(currentData, (draft) => {
|
||||
const sub = draft.find((i) => i.feed.id === item.feed.id)
|
||||
const sub = draft.find(
|
||||
(i) => i.feed?.id === item.feed?.id || i.list?.id === item.list?.id,
|
||||
)
|
||||
if (!sub) return
|
||||
sub.isSubscribed = false
|
||||
sub.subscriptionCount = Number.isNaN(sub.subscriptionCount)
|
||||
|
|
@ -167,6 +176,14 @@ export function DiscoverForm({ type }: { type: string }) {
|
|||
},
|
||||
[discoverSearchDataAtom, jotaiStore],
|
||||
)
|
||||
|
||||
const handleTargetChange = useCallback(
|
||||
(value: string) => {
|
||||
form.setValue("target", value as "feeds" | "lists")
|
||||
},
|
||||
[form],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
|
|
@ -184,6 +201,30 @@ export function DiscoverForm({ type }: { type: string }) {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "search" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target"
|
||||
render={({ field }) => (
|
||||
<FormItem className="!mt-4 flex items-center justify-between">
|
||||
<FormLabel>{t("discover.target")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<RadioGroup
|
||||
className="flex items-center"
|
||||
value={field.value}
|
||||
onValueChange={handleTargetChange}
|
||||
>
|
||||
<Radio label={t("discover.target.feeds")} value="feeds" />
|
||||
<Radio label={t("discover.target.lists")} value="lists" />
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="center flex">
|
||||
<Button disabled={!form.formState.isValid} type="submit" isLoading={mutation.isPending}>
|
||||
{info[type].showModal ? t("discover.preview") : t("words.search")}
|
||||
|
|
@ -200,7 +241,7 @@ export function DiscoverForm({ type }: { type: string }) {
|
|||
<div className="space-y-6 text-sm">
|
||||
{discoverSearchData?.map((item) => (
|
||||
<SearchCard
|
||||
key={item.feed.id}
|
||||
key={item.feed?.id || item.list?.id}
|
||||
item={item}
|
||||
onSuccess={handleSuccess}
|
||||
onUnSubscribed={handleUnSubscribed}
|
||||
|
|
@ -221,9 +262,9 @@ const SearchCard: FC<{
|
|||
const { present } = useModalStack()
|
||||
|
||||
return (
|
||||
<Card data-feed-id={item.feed.id} key={item.feed.url || item.docs} className="select-text">
|
||||
<Card data-feed-id={item.feed?.id || item.list?.id} className="select-text">
|
||||
<CardHeader>
|
||||
<FollowSummary className="max-w-[462px]" feed={item.feed} docs={item.docs} />
|
||||
<FollowSummary className="max-w-[462px]" feed={item.feed || item.list!} docs={item.docs} />
|
||||
</CardHeader>
|
||||
{item.docs ? (
|
||||
<CardFooter>
|
||||
|
|
@ -282,8 +323,9 @@ const SearchCard: FC<{
|
|||
content: ({ dismiss }) => (
|
||||
<FeedForm
|
||||
asWidget
|
||||
url={item.feed.url}
|
||||
id={item.feed.id}
|
||||
url={item.feed?.url}
|
||||
id={item.feed?.id || item.list?.id}
|
||||
isList={!!item.list?.id}
|
||||
defaultValues={{
|
||||
view: getSidebarActiveView().toString(),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import { Input } from "~/components/ui/input"
|
|||
import { apiFetch } from "~/lib/api-fetch"
|
||||
import { toastFetchError } from "~/lib/error-parser"
|
||||
import { cn } from "~/lib/utils"
|
||||
import type { FeedResponse } from "~/models"
|
||||
import { Queries } from "~/queries"
|
||||
|
||||
import { FollowSummary } from "../../components/feed-summary"
|
||||
|
|
@ -171,7 +170,7 @@ export function DiscoverImport() {
|
|||
{!mutation.data?.[item.key].length && (
|
||||
<div className="text-zinc-500">No items</div>
|
||||
)}
|
||||
{mutation.data?.[item.key].map((feed: FeedResponse) => (
|
||||
{mutation.data?.[item.key].map((feed) => (
|
||||
<FollowSummary className="max-w-[462px]" key={feed.id} feed={feed} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,20 +25,21 @@ export const RecommendationCard: FC<RecommendationCardProps> = memo(({ data, rou
|
|||
<CardContent className="p-5 pt-0">
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{Object.keys(data.routes).map((route) => (
|
||||
<li
|
||||
key={route}
|
||||
className="duration-200 hover:text-theme-foreground-hover"
|
||||
onClick={() => {
|
||||
present({
|
||||
content: () => (
|
||||
<RecommendationContent routePrefix={routePrefix} route={data.routes[route]} />
|
||||
),
|
||||
icon: <FeedIcon className="size-4" size={16} siteUrl={`https://${data.url}`} />,
|
||||
title: `${data.name} - ${data.routes[route].name}`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{data.routes[route].name}
|
||||
<li key={route} className="duration-200 hover:text-theme-foreground-hover">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
present({
|
||||
content: () => (
|
||||
<RecommendationContent routePrefix={routePrefix} route={data.routes[route]} />
|
||||
),
|
||||
icon: <FeedIcon className="size-4" size={16} siteUrl={`https://${data.url}`} />,
|
||||
title: `${data.name} - ${data.routes[route].name}`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{data.routes[route].name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -58,10 +58,10 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
|
|||
<div ref={ref} className="ml-2 min-w-0 flex-1">
|
||||
<div className={cn("-mt-0.5 flex-1 text-sm", content && "line-clamp-[10]")}>
|
||||
<div className="w-[calc(100%-10rem)] space-x-1 leading-6">
|
||||
<span className="text-base font-semibold">
|
||||
{entry.entries.author}
|
||||
<span className="inline-flex items-center gap-1 text-base font-semibold">
|
||||
<span>{entry.entries.author || feed.title}</span>
|
||||
{parsed?.type === "x" && (
|
||||
<i className="i-mgc-twitter-cute-fi ml-1 size-3 text-[#4A99E9]" />
|
||||
<i className="i-mgc-twitter-cute-fi size-3 text-[#4A99E9]" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { m } from "~/components/common/Motion"
|
|||
import { Media } from "~/components/ui/media"
|
||||
import type { ModalContentComponent } from "~/components/ui/modal"
|
||||
import { useModalStack } from "~/components/ui/modal"
|
||||
import { NoopChildren } from "~/components/ui/modal/stacked/utils"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { Skeleton } from "~/components/ui/skeleton"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { urlToIframe } from "~/lib/url-to-iframe"
|
||||
|
|
@ -58,14 +58,14 @@ export function VideoItem({ entryId, entryPreview, translation }: UniversalItemP
|
|||
return (
|
||||
<GridItem entryId={entryId} entryPreview={entryPreview} translation={translation}>
|
||||
<div
|
||||
className="w-full"
|
||||
className="w-full cursor-card"
|
||||
onClick={() => {
|
||||
if (iframeSrc) {
|
||||
modalStack.present({
|
||||
title: "",
|
||||
content: (props) => <PreviewVideoModalContent src={iframeSrc} {...props} />,
|
||||
clickOutsideToDismiss: true,
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
overlay: true,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,17 @@ export const useMarkAllByRoute = (filter?: MarkAllFilter) => {
|
|||
if (typeof routerParams.feedId === "number" || routerParams.isAllFeeds) {
|
||||
subscriptionActions.markReadByView(view, filter)
|
||||
} else if (folderIds) {
|
||||
subscriptionActions.markReadByFeedIds(folderIds, view, filter)
|
||||
subscriptionActions.markReadByFeedIds({
|
||||
feedIds: folderIds,
|
||||
view,
|
||||
filter,
|
||||
})
|
||||
} else if (routerParams.feedId) {
|
||||
subscriptionActions.markReadByFeedIds(routerParams.feedId?.split(","), view, filter)
|
||||
subscriptionActions.markReadByFeedIds({
|
||||
feedIds: routerParams.feedId?.split(","),
|
||||
view,
|
||||
filter,
|
||||
})
|
||||
}
|
||||
}, [routerParams, folderIds, view, filter])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRoutePara
|
|||
import { useTitle, useTypeScriptHappyCallback } from "~/hooks/common"
|
||||
import { FeedViewType } from "~/lib/enum"
|
||||
import { cn, isBizId } from "~/lib/utils"
|
||||
import type { FeedModel } from "~/models"
|
||||
import { useFeed } from "~/queries/feed"
|
||||
import { entryActions, getEntry, useEntry } from "~/store/entry"
|
||||
import { useFeedByIdSelector } from "~/store/feed"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { useSubscriptionByFeedId } from "~/store/subscription"
|
||||
|
||||
import { useEntriesByView, useEntryMarkReadHandler } from "./hooks"
|
||||
|
|
@ -64,8 +65,8 @@ function EntryColumnImpl() {
|
|||
isCollection,
|
||||
} = useRouteParams()
|
||||
const activeEntry = useEntry(activeEntryId)
|
||||
const feedTitle = useFeedByIdSelector(routeFeedId, (feed) => feed?.title)
|
||||
useTitle(feedTitle)
|
||||
const feed = useFeedById(routeFeedId)
|
||||
useTitle(feed?.title)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEntryId) return
|
||||
|
|
@ -97,7 +98,9 @@ function EntryColumnImpl() {
|
|||
!unreadOnly &&
|
||||
!isCollection &&
|
||||
routeFeedId !== ROUTE_FEED_PENDING &&
|
||||
entries.totalCount < 40
|
||||
entries.totalCount < 40 &&
|
||||
feed?.type !== "list"
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const virtuosoOptions = {
|
||||
components: {
|
||||
|
|
@ -348,7 +351,7 @@ const AddFeedHelper = () => {
|
|||
return null
|
||||
}
|
||||
|
||||
throw new FeedFoundCanBeFollowError(feedQuery.data.feed)
|
||||
throw new FeedFoundCanBeFollowError(feedQuery.data.feed as FeedModel)
|
||||
}
|
||||
|
||||
export const EntryColumn = memo(EntryColumnImpl)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export const EntryListHeader: FC<{
|
|||
const isOnline = useIsOnline()
|
||||
|
||||
const feed = useFeedById(routerParams.feedId)
|
||||
const isList = feed?.type === "list"
|
||||
|
||||
const titleStyleBasedView = ["pl-12", "pl-7", "pl-7", "pl-7", "px-5", "pl-12"]
|
||||
|
||||
|
|
@ -103,7 +104,9 @@ export const EntryListHeader: FC<{
|
|||
</AppendTaildingDivider>
|
||||
|
||||
{isOnline ? (
|
||||
feed?.ownerUserId === user?.id && isBizId(routerParams.feedId!) ? (
|
||||
feed?.ownerUserId === user?.id &&
|
||||
isBizId(routerParams.feedId!) &&
|
||||
feed?.type === "feed" ? (
|
||||
<ActionButton
|
||||
tooltip="Refresh"
|
||||
onClick={() => {
|
||||
|
|
@ -130,22 +133,26 @@ export const EntryListHeader: FC<{
|
|||
</ActionButton>
|
||||
)
|
||||
) : null}
|
||||
<ActionButton
|
||||
tooltip={
|
||||
!unreadOnly
|
||||
? t("entry_list_header.show_unread_only")
|
||||
: t("entry_list_header.show_all")
|
||||
}
|
||||
shortcut={shortcuts.entries.toggleUnreadOnly.key}
|
||||
onClick={() => setGeneralSetting("unreadOnly", !unreadOnly)}
|
||||
>
|
||||
{unreadOnly ? (
|
||||
<i className="i-mgc-round-cute-fi" />
|
||||
) : (
|
||||
<i className="i-mgc-round-cute-re" />
|
||||
)}
|
||||
</ActionButton>
|
||||
<MarkAllReadWithOverlay containerRef={containerRef} shortcut />
|
||||
{!isList && (
|
||||
<>
|
||||
<ActionButton
|
||||
tooltip={
|
||||
!unreadOnly
|
||||
? t("entry_list_header.show_unread_only")
|
||||
: t("entry_list_header.show_all")
|
||||
}
|
||||
shortcut={shortcuts.entries.toggleUnreadOnly.key}
|
||||
onClick={() => setGeneralSetting("unreadOnly", !unreadOnly)}
|
||||
>
|
||||
{unreadOnly ? (
|
||||
<i className="i-mgc-round-cute-fi" />
|
||||
) : (
|
||||
<i className="i-mgc-round-cute-re" />
|
||||
)}
|
||||
</ActionButton>
|
||||
<MarkAllReadWithOverlay containerRef={containerRef} shortcut />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{titleAtBottom && titleInfo}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function ListItem({
|
|||
onMouseEnter={handlePrefetchEntry}
|
||||
onMouseLeave={handlePrefetchEntry.cancel}
|
||||
className={cn(
|
||||
"group relative flex py-4 pl-3 pr-2",
|
||||
"group relative flex cursor-menu py-4 pl-3 pr-2",
|
||||
!asRead &&
|
||||
"before:absolute before:-left-0.5 before:top-[1.4375rem] before:block before:size-2 before:rounded-full before:bg-accent",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { FC } from "react"
|
||||
|
||||
import type { CombinedEntryModel, FeedModel } from "~/models"
|
||||
import type { CombinedEntryModel, TargetModel } from "~/models"
|
||||
|
||||
export type UniversalItemProps = {
|
||||
entryId: string
|
||||
entryPreview?: CombinedEntryModel & {
|
||||
feeds: FeedModel
|
||||
feeds: TargetModel
|
||||
feedId: string
|
||||
}
|
||||
translation?: {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import {
|
|||
HoverCardTrigger,
|
||||
} from "@radix-ui/react-hover-card"
|
||||
import clsx from "clsx"
|
||||
import { LayoutGroup, m } from "framer-motion"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import { m } from "framer-motion"
|
||||
import { memo } from "react"
|
||||
|
||||
import { useWhoami } from "~/atoms/user"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { useAppLayoutGridContainerWidth } from "~/providers/app-grid-layout-container-provider"
|
||||
import { Queries } from "~/queries"
|
||||
import { useEntryReadHistory } from "~/store/entry"
|
||||
import { useUserById } from "~/store/user"
|
||||
|
|
@ -22,20 +23,14 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
|
|||
const me = useWhoami()
|
||||
const entryHistory = useEntryReadHistory(entryId)
|
||||
|
||||
const [isEnabledPolling, setIsEnabledPolling] = useState(false)
|
||||
useAuthQuery(Queries.entries.entryReadingHistory(entryId), {
|
||||
const { data } = useAuthQuery(Queries.entries.entryReadingHistory(entryId), {
|
||||
refetchInterval: 1000 * 60,
|
||||
enabled: isEnabledPolling,
|
||||
})
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsEnabledPolling(true)
|
||||
}, 1000 * 60)
|
||||
const totalCount = data?.total || 0
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [entryId])
|
||||
const appGirdContainerWidth = useAppLayoutGridContainerWidth()
|
||||
|
||||
const LIMIT = appGirdContainerWidth > 600 ? 10 : 5
|
||||
|
||||
if (!entryHistory) return null
|
||||
if (!me) return null
|
||||
|
|
@ -43,36 +38,32 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
|
|||
|
||||
return (
|
||||
<div className="hidden items-center duration-200 animate-in fade-in @md:flex">
|
||||
<LayoutGroup>
|
||||
{entryHistory.userIds
|
||||
.filter((id) => id !== me?.id)
|
||||
.slice(0, 10)
|
||||
{entryHistory.userIds
|
||||
.filter((id) => id !== me?.id)
|
||||
.slice(0, LIMIT)
|
||||
|
||||
.map((userId, i) => (
|
||||
<EntryUser userId={userId} i={i} key={userId} />
|
||||
))}
|
||||
</LayoutGroup>
|
||||
.map((userId, i) => (
|
||||
<EntryUser userId={userId} i={i} key={userId} />
|
||||
))}
|
||||
|
||||
{entryHistory.readCount &&
|
||||
entryHistory.readCount > 10 &&
|
||||
entryHistory.userIds &&
|
||||
entryHistory.userIds.length >= 10 && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
right: "80px",
|
||||
zIndex: 11,
|
||||
}}
|
||||
className="no-drag-region relative flex size-7 items-center justify-center rounded-full border border-border bg-muted ring ring-background"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
+{Math.min(entryHistory.readCount - 10, 99)}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
{!!totalCount && totalCount > LIMIT && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
right: `${LIMIT * 8}px`,
|
||||
zIndex: 11,
|
||||
}}
|
||||
className="no-drag-region relative flex size-7 items-center justify-center rounded-full border border-border bg-muted ring ring-background"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
+{Math.min(totalCount - LIMIT, 99)}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
|
||||
{totalCount > LIMIT && (
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
sideOffset={12}
|
||||
|
|
@ -88,15 +79,16 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
|
|||
<ul>
|
||||
{entryHistory.userIds
|
||||
.filter((id) => id !== me?.id)
|
||||
.slice(10)
|
||||
.slice(LIMIT)
|
||||
.map((userId) => (
|
||||
<EntryUserRow userId={userId} key={userId} />
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
)}
|
||||
)}
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import { stopPropagation } from "~/lib/dom"
|
|||
import { FeedViewType } from "~/lib/enum"
|
||||
import { getNewIssueUrl } from "~/lib/issues"
|
||||
import { cn } from "~/lib/utils"
|
||||
import type { ActiveEntryId } from "~/models"
|
||||
import type { ActiveEntryId, FeedModel } from "~/models"
|
||||
import {
|
||||
useIsSoFWrappedElement,
|
||||
useWrappedElement,
|
||||
|
|
@ -97,7 +97,7 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, cl
|
|||
const entry = useEntry(entryId)
|
||||
useTitle(entry?.entries.title)
|
||||
|
||||
const feed = useFeedById(entry?.feedId)
|
||||
const feed = useFeedById(entry?.feedId) as FeedModel
|
||||
|
||||
const entryHistory = useEntryReadHistory(entryId)
|
||||
|
||||
|
|
@ -219,7 +219,7 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, cl
|
|||
<a
|
||||
href={populatedFullHref || void 0}
|
||||
target="_blank"
|
||||
className="-mx-6 block cursor-default rounded-lg p-6 transition-colors hover:bg-theme-item-hover focus-visible:bg-theme-item-hover focus-visible:!outline-none @sm:-mx-3 @sm:p-3"
|
||||
className="-mx-6 block cursor-button rounded-lg p-6 transition-colors hover:bg-theme-item-hover focus-visible:bg-theme-item-hover focus-visible:!outline-none @sm:-mx-3 @sm:p-3"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="select-text break-words text-3xl font-bold">
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ function FeedCategoryImpl({
|
|||
{!!showCollapse && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-2.5 transition-colors",
|
||||
"flex w-full cursor-menu items-center justify-between rounded-md px-2.5 transition-colors",
|
||||
(isActive || isContextMenuOpen) && "bg-native-active",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
|
@ -155,7 +155,9 @@ function FeedCategoryImpl({
|
|||
type: "text",
|
||||
label: t("sidebar.feed_column.context_menu.mark_as_read"),
|
||||
click: () => {
|
||||
subscriptionActions.markReadByFeedIds(ids)
|
||||
subscriptionActions.markReadByFeedIds({
|
||||
feedIds: ids,
|
||||
})
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { m } from "framer-motion"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { memo, useCallback, useRef, useState } from "react"
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -34,9 +35,9 @@ const useBackHome = (active: number) => {
|
|||
|
||||
export const FeedColumnHeader = memo(() => {
|
||||
const [active] = useSidebarActiveView()
|
||||
const { t } = useTranslation()
|
||||
const navigateBackHome = useBackHome(active)
|
||||
const normalStyle = !window.electron || window.electron.process.platform !== "darwin"
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -60,13 +61,13 @@ export const FeedColumnHeader = memo(() => {
|
|||
</LogoContextMenu>
|
||||
)}
|
||||
<div className="relative flex items-center gap-1" onClick={stopPropagation}>
|
||||
<SearchActionButton />
|
||||
|
||||
<Link to="/discover" tabIndex={-1}>
|
||||
<ActionButton shortcut="Meta+T" tooltip={t("words.add")}>
|
||||
<ActionButton shortcut="Meta+T" tooltip={t("words.discover")}>
|
||||
<i className="i-mgc-add-cute-re size-5 text-theme-vibrancyFg" />
|
||||
</ActionButton>
|
||||
</Link>
|
||||
<SearchTrigger />
|
||||
|
||||
<ProfileButton method="modal" />
|
||||
<LayoutActionButton />
|
||||
</div>
|
||||
|
|
@ -77,10 +78,16 @@ export const FeedColumnHeader = memo(() => {
|
|||
const LayoutActionButton = () => {
|
||||
const feedColumnShow = useFeedColumnShow()
|
||||
|
||||
const [animation] = useState({
|
||||
const [animation, setAnimation] = useState({
|
||||
width: !feedColumnShow ? "auto" : 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setAnimation({
|
||||
width: !feedColumnShow ? "auto" : 0,
|
||||
})
|
||||
}, [feedColumnShow])
|
||||
|
||||
const t = useI18n()
|
||||
return (
|
||||
<m.div initial={animation} animate={animation} className="overflow-hidden">
|
||||
|
|
@ -126,7 +133,7 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
|
|||
toast.success(t.common("app.copied_to_clipboard"))
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-1 py-0.5 text-sm outline-none",
|
||||
"relative flex cursor-menu select-none items-center rounded-sm px-1 py-0.5 text-sm outline-none",
|
||||
"focus-within:outline-transparent hover:bg-theme-item-hover dark:hover:bg-neutral-800",
|
||||
"gap-2 text-foreground/80 [&_svg]:size-3",
|
||||
)}
|
||||
|
|
@ -139,17 +146,33 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const SearchActionButton = () => {
|
||||
// const SearchActionButton = () => {
|
||||
// const canSearch = useGeneralSettingKey("dataPersist")
|
||||
// const { t } = useTranslation()
|
||||
// if (!canSearch) return null
|
||||
// return (
|
||||
// <ActionButton
|
||||
// shortcut="Meta+K"
|
||||
// tooltip={t("words.search")}
|
||||
// onClick={() => setAppSearchOpen(true)}
|
||||
// >
|
||||
// <i className="i-mgc-search-2-cute-re size-5 text-theme-vibrancyFg" />
|
||||
// </ActionButton>
|
||||
// )
|
||||
// }
|
||||
|
||||
const SearchTrigger = () => {
|
||||
const canSearch = useGeneralSettingKey("dataPersist")
|
||||
const { t } = useTranslation()
|
||||
if (!canSearch) return null
|
||||
return (
|
||||
<ActionButton
|
||||
shortcut="Meta+K"
|
||||
tooltip={t("words.search")}
|
||||
onClick={() => setAppSearchOpen(true)}
|
||||
>
|
||||
<i className="i-mgc-search-2-cute-re size-5 text-theme-vibrancyFg" />
|
||||
</ActionButton>
|
||||
|
||||
useHotkeys(
|
||||
"meta+k",
|
||||
() => {
|
||||
setAppSearchOpen(true)
|
||||
},
|
||||
{
|
||||
enabled: canSearch,
|
||||
},
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,10 +88,12 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam
|
|||
}
|
||||
}, [setActive_])
|
||||
|
||||
const [useHotkeysSwitch, setUseHotkeysSwitch] = useState<boolean>(false)
|
||||
useHotkeys(
|
||||
shortcuts.feeds.switchBetweenViews.key,
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
setUseHotkeysSwitch(true)
|
||||
if (isHotkeyPressed("Left")) {
|
||||
setActive((i) => {
|
||||
if (i === 0) {
|
||||
|
|
@ -161,9 +163,11 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam
|
|||
"flex flex-col items-center gap-1 text-xl",
|
||||
ELECTRON ? "hover:!bg-theme-vibrancyBg" : "",
|
||||
showSidebarUnreadCount && "h-11",
|
||||
active === index && useHotkeysSwitch ? "bg-zinc-500/30" : "",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setActive(index)
|
||||
setUseHotkeysSwitch(false)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { getNewIssueUrl } from "~/lib/issues"
|
|||
import { showNativeMenu } from "~/lib/native-menu"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { getPreferredTitle, useFeedById } from "~/store/feed"
|
||||
import { useSubscriptionByFeedId } from "~/store/subscription"
|
||||
import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription"
|
||||
import { useFeedUnreadStore } from "~/store/unread"
|
||||
|
||||
import { UnreadNumber } from "./unread-number"
|
||||
|
|
@ -32,6 +32,8 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
|
|||
const { t } = useTranslation()
|
||||
const subscription = useSubscriptionByFeedId(feedId)
|
||||
const navigate = useNavigateEntry()
|
||||
const feed = useFeedById(feedId)
|
||||
|
||||
const handleNavigate: React.MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -41,20 +43,23 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
|
|||
entryId: null,
|
||||
view,
|
||||
})
|
||||
if (feed?.type === "list") {
|
||||
subscriptionActions.markReadByFeedIds({
|
||||
listId: feedId,
|
||||
})
|
||||
}
|
||||
// focus to main container in order to let keyboard can navigate entry items by arrow keys
|
||||
nextFrame(() => {
|
||||
getMainContainerElement()?.focus()
|
||||
})
|
||||
},
|
||||
[feedId, navigate, view],
|
||||
[feedId, navigate, view, feed?.type],
|
||||
)
|
||||
|
||||
const feedUnread = useFeedUnreadStore((state) => state.data[feedId] || 0)
|
||||
|
||||
const isActive = useRouteParamsSelector((routerParams) => routerParams.feedId === feedId)
|
||||
|
||||
const feed = useFeedById(feedId)
|
||||
|
||||
const { items } = useFeedActions({ feedId, view })
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
|
|
@ -63,14 +68,16 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
|
|||
})
|
||||
if (!feed) return null
|
||||
|
||||
const isFeed = feed.type === "feed" || !feed.type
|
||||
const isList = feed.type === "list"
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-feed-id={feedId}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md py-[2px] pr-2.5 text-sm font-medium leading-loose",
|
||||
"flex w-full cursor-menu items-center justify-between rounded-md py-[2px] pr-2.5 text-sm font-medium leading-loose",
|
||||
(isActive || isContextMenuOpen) && "bg-native-active",
|
||||
|
||||
isFeed ? "py-[2px]" : "py-1.5",
|
||||
className,
|
||||
)}
|
||||
onClick={handleNavigate}
|
||||
|
|
@ -80,7 +87,7 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
|
|||
onContextMenu={(e) => {
|
||||
setIsContextMenuOpen(true)
|
||||
const nextItems = items.concat()
|
||||
if (feed.errorAt && feed.errorMessage) {
|
||||
if (isFeed && feed.errorAt && feed.errorMessage) {
|
||||
nextItems.push(
|
||||
{
|
||||
type: "separator",
|
||||
|
|
@ -109,10 +116,10 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
|
|||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center",
|
||||
feed.errorAt && "text-red-900 dark:text-red-500",
|
||||
isFeed && feed.errorAt && "text-red-900 dark:text-red-500",
|
||||
)}
|
||||
>
|
||||
<FeedIcon fallback feed={feed} size={16} />
|
||||
<FeedIcon fallback feed={feed} size={isList ? 32 : 16} />
|
||||
<div
|
||||
className={cn(
|
||||
"truncate",
|
||||
|
|
@ -121,8 +128,8 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
|
|||
>
|
||||
{getPreferredTitle(feed)}
|
||||
</div>
|
||||
<FeedCertification feed={feed} className="text-[15px]" />
|
||||
{feed.errorAt && (
|
||||
{isFeed && <FeedCertification feed={feed} className="text-[15px]" />}
|
||||
{isFeed && feed.errorAt && (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<i className="i-mgc-wifi-off-cute-re ml-1 shrink-0 text-base" />
|
||||
|
|
@ -157,7 +164,7 @@ const FeedItemImpl = ({ view, feedId, className, showUnreadCount = true }: FeedI
|
|||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<UnreadNumber unread={feedUnread} className="ml-2" />
|
||||
<UnreadNumber unread={feedUnread} className="ml-2" isList={isList} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
import { FeedCategory } from "./category"
|
||||
import { UnreadNumber } from "./unread-number"
|
||||
|
||||
const useGroupedData = (view: FeedViewType) => {
|
||||
const useFeedsGroupedData = (view: FeedViewType) => {
|
||||
const { data: remoteData } = useAuthQuery(Queries.subscription.byView(view))
|
||||
|
||||
const data = useSubscriptionByView(view) || remoteData
|
||||
|
|
@ -53,6 +53,26 @@ const useGroupedData = (view: FeedViewType) => {
|
|||
}, [data])
|
||||
}
|
||||
|
||||
const useListsGroupedData = (view: FeedViewType) => {
|
||||
const { data: remoteData } = useAuthQuery(Queries.subscription.byView(view))
|
||||
|
||||
const data = useSubscriptionByView(view) || remoteData
|
||||
|
||||
return useMemo(() => {
|
||||
if (!data || data.length === 0) return {}
|
||||
|
||||
const groupFolder = {} as Record<string, string[]>
|
||||
|
||||
for (const subscription of data) {
|
||||
if (!subscription.category && !subscription.defaultCategory) {
|
||||
groupFolder[subscription.feedId] = [subscription.feedId]
|
||||
}
|
||||
}
|
||||
|
||||
return groupFolder
|
||||
}, [data])
|
||||
}
|
||||
|
||||
const useUpdateUnreadCount = () => {
|
||||
useAuthQuery(Queries.subscription.unreadAll(), {
|
||||
refetchInterval: false,
|
||||
|
|
@ -61,40 +81,38 @@ const useUpdateUnreadCount = () => {
|
|||
|
||||
function FeedListImpl({ className, view }: { className?: string; view: number }) {
|
||||
const [expansion, setExpansion] = useState(false)
|
||||
const data = useGroupedData(view)
|
||||
const feedsData = useFeedsGroupedData(view)
|
||||
const listsData = useListsGroupedData(view)
|
||||
|
||||
useUpdateUnreadCount()
|
||||
|
||||
const totalUnread = useFeedUnreadStore((state) => {
|
||||
let unread = 0
|
||||
|
||||
for (const category in data) {
|
||||
for (const feedId of data[category]) {
|
||||
for (const category in feedsData) {
|
||||
for (const feedId of feedsData[category]) {
|
||||
unread += state.data[feedId] || 0
|
||||
}
|
||||
}
|
||||
return unread
|
||||
})
|
||||
|
||||
const hasData = Object.keys(data).length > 0
|
||||
const hasData = Object.keys(feedsData).length > 0 || Object.keys(listsData).length > 0
|
||||
|
||||
const feedId = useRouteFeedId()
|
||||
const navigate = useNavigateEntry()
|
||||
const navigateEntry = useNavigateEntry()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn(className, "font-medium")}>
|
||||
<div
|
||||
onClick={stopPropagation}
|
||||
className="mx-3 mb-2 flex items-center justify-between px-2.5 py-1"
|
||||
>
|
||||
<div onClick={stopPropagation} className="mx-3 flex items-center justify-between px-2.5 py-1">
|
||||
<div
|
||||
className="font-bold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (view !== undefined) {
|
||||
navigate({
|
||||
navigateEntry({
|
||||
entryId: null,
|
||||
feedId: null,
|
||||
view,
|
||||
|
|
@ -114,16 +132,17 @@ function FeedListImpl({ className, view }: { className?: string; view: number })
|
|||
<UnreadNumber unread={totalUnread} className="text-xs !text-inherit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea.ScrollArea mask={false} flex viewportClassName="!px-3" rootClassName="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-full shrink-0 items-center rounded-md px-2.5 transition-colors",
|
||||
"mt-1 flex h-8 w-full shrink-0 cursor-menu items-center gap-2 rounded-md px-2.5 transition-colors",
|
||||
feedId === FEED_COLLECTION_LIST && "bg-native-active",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (view !== undefined) {
|
||||
navigate({
|
||||
navigateEntry({
|
||||
entryId: null,
|
||||
feedId: FEED_COLLECTION_LIST,
|
||||
view,
|
||||
|
|
@ -131,16 +150,32 @@ function FeedListImpl({ className, view }: { className?: string; view: number })
|
|||
}
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-star-cute-fi mr-2 text-orange-500" />
|
||||
<i className="i-mgc-star-cute-fi size-4 -translate-y-px text-amber-500" />
|
||||
{t("words.starred")}
|
||||
</div>
|
||||
{Object.keys(listsData).length > 0 && (
|
||||
<>
|
||||
<div className="mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors">
|
||||
{t("words.lists")}
|
||||
</div>
|
||||
<SortableList view={view} expansion={expansion} data={listsData} />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors",
|
||||
Object.keys(feedsData).length === 0 ? "mt-0" : "mt-1",
|
||||
)}
|
||||
>
|
||||
{t("words.feeds")}
|
||||
</div>
|
||||
{hasData ? (
|
||||
<SortableList view={view} expansion={expansion} data={data} />
|
||||
<SortableList view={view} expansion={expansion} data={feedsData} />
|
||||
) : (
|
||||
<div className="flex h-full flex-1 items-center font-normal text-zinc-500">
|
||||
<Link
|
||||
to="/discover"
|
||||
className="absolute inset-0 mt-[-3.75rem] flex h-full flex-1 cursor-default flex-col items-center justify-center gap-2"
|
||||
className="absolute inset-0 mt-[-3.75rem] flex h-full flex-1 cursor-menu flex-col items-center justify-center gap-2"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<i className="i-mgc-add-cute-re text-3xl" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export const UnreadNumber = ({ unread, className }: { unread: number; className?: string }) => {
|
||||
export const UnreadNumber = ({
|
||||
unread,
|
||||
className,
|
||||
isList,
|
||||
}: {
|
||||
unread: number
|
||||
className?: string
|
||||
isList?: boolean
|
||||
}) => {
|
||||
const showUnreadCount = useUISettingKey("sidebarShowUnreadCount")
|
||||
if (!showUnreadCount) return null
|
||||
if (!unread) return null
|
||||
|
|
@ -9,7 +17,11 @@ export const UnreadNumber = ({ unread, className }: { unread: number; className?
|
|||
<div
|
||||
className={cn("text-[0.65rem] tabular-nums text-zinc-400 dark:text-neutral-500", className)}
|
||||
>
|
||||
{unread}
|
||||
{isList ? (
|
||||
<i className="block size-1.5 rounded-full bg-zinc-400 dark:bg-neutral-500" />
|
||||
) : (
|
||||
unread
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useUISettingKey } from "~/atoms/settings/ui"
|
|||
import { MotionButtonBase } from "~/components/ui/button"
|
||||
import { KbdCombined } from "~/components/ui/kbd/Kbd"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal"
|
||||
import { NoopChildren } from "~/components/ui/modal/stacked/utils"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useSwitchHotKeyScope } from "~/hooks/common"
|
||||
|
|
@ -89,7 +89,7 @@ export const useShortcutsModal = () => {
|
|||
overlay: false,
|
||||
id: "shortcuts",
|
||||
content: () => <ShortcutModalContent />,
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
clickOutsideToDismiss: true,
|
||||
})
|
||||
}, [present])
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export const SearchCmdK: React.FC = () => {
|
|||
feedId={entry.feedId}
|
||||
entryId={entry.item.id}
|
||||
id={entry.item.id}
|
||||
icon={feed?.siteUrl}
|
||||
icon={feed?.type === "feed" ? feed?.siteUrl : undefined}
|
||||
subtitle={feed?.title}
|
||||
/>
|
||||
)
|
||||
|
|
@ -203,7 +203,7 @@ export const SearchCmdK: React.FC = () => {
|
|||
feedId={feed.item.id!}
|
||||
entryId={ROUTE_ENTRY_PENDING}
|
||||
id={feed.item.id!}
|
||||
icon={feed.item.siteUrl}
|
||||
icon={feed.item.type === "feed" ? feed.item.siteUrl : undefined}
|
||||
subtitle={useFeedUnreadStore.getState().data[feed.item.id!]?.toString()}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { getSidebarActiveView } from "~/atoms/sidebar"
|
|||
import { m } from "~/components/common/Motion"
|
||||
import { Form, FormControl, FormField, FormItem } from "~/components/ui/form"
|
||||
import { useModalStack } from "~/components/ui/modal"
|
||||
import { NoopChildren } from "~/components/ui/modal/stacked/utils"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { HotKeyScopeMap } from "~/constants"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import type { FeedViewType } from "~/lib/enum"
|
||||
|
|
@ -108,7 +108,7 @@ export const CmdNTrigger = () => {
|
|||
present({
|
||||
title: "Quick Follow",
|
||||
content: CmdNPanel,
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
overlay: false,
|
||||
id: "quick-add",
|
||||
clickOutsideToDismiss: true,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createElement, useCallback } from "react"
|
|||
import { parse } from "tldts"
|
||||
|
||||
import { useModalStack } from "~/components/ui/modal"
|
||||
import { NoopChildren } from "~/components/ui/modal/stacked/utils"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { defineQuery } from "~/lib/defineQuery"
|
||||
|
|
@ -19,7 +19,7 @@ export const useUserSubscriptionsQuery = (userId: string | undefined) => {
|
|||
const groupFolder = {} as Record<string, typeof res.data>
|
||||
|
||||
for (const subscription of res.data || []) {
|
||||
if (!subscription.category && subscription.feeds) {
|
||||
if (!subscription.category && "feeds" in subscription) {
|
||||
const { siteUrl } = subscription.feeds
|
||||
if (!siteUrl) continue
|
||||
const parsed = parse(siteUrl)
|
||||
|
|
@ -55,7 +55,7 @@ export const usePresentUserProfileModal = (variant: "drawer" | "dialog" = "dialo
|
|||
userId,
|
||||
variant,
|
||||
}),
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
clickOutsideToDismiss: true,
|
||||
modal: variant === "dialog",
|
||||
overlay: variant === "dialog",
|
||||
|
|
|
|||
|
|
@ -102,14 +102,17 @@ export const ProfileSettingForm = () => {
|
|||
<FormItem className="w-full">
|
||||
<FormLabel>{t("profile.avatar.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<div className="flex items-center gap-4">
|
||||
<Input {...field} />
|
||||
{field.value && (
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage src={field.value} />
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<Avatar className="-translate-y-2 self-end">
|
||||
<AvatarImage src={field.value} />
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ const SubscriptionItem: FC<{
|
|||
const isFollowed = !!useSubscriptionStore((state) => state.data[subscription.feedId])
|
||||
const { present } = useModalStack()
|
||||
const isLoose = variant === "loose"
|
||||
if (!("feeds" in subscription)) return null
|
||||
return (
|
||||
<m.div
|
||||
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.2 } }}
|
||||
|
|
@ -373,7 +374,7 @@ const SubscriptionItem: FC<{
|
|||
data-feed-id={subscription.feedId}
|
||||
>
|
||||
<a
|
||||
className="flex flex-1 cursor-default items-center"
|
||||
className="flex flex-1 cursor-menu items-center"
|
||||
href={subscription.feeds.siteUrl!}
|
||||
target="_blank"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const SettingSwitch: Component<{
|
|||
return (
|
||||
<div className={cn("mb-3 flex items-center justify-between gap-4", className)}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} className="cursor-auto" />
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import { repository } from "@pkg"
|
|||
import i18next from "i18next"
|
||||
import type { FC } from "react"
|
||||
import { Suspense, useDeferredValue, useEffect, useLayoutEffect, useState } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { Trans } from "react-i18next"
|
||||
|
||||
import { MotionButtonBase } from "~/components/ui/button"
|
||||
import { useCurrentModal } from "~/components/ui/modal"
|
||||
import { ModalClose } from "~/components/ui/modal/stacked/components"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { SettingsTitle } from "~/modules/settings/title"
|
||||
|
||||
|
|
@ -43,21 +42,6 @@ export const SettingModalContent: FC<{
|
|||
)
|
||||
}
|
||||
|
||||
const Close = () => {
|
||||
const { dismiss } = useCurrentModal()
|
||||
const { t } = useTranslation("common")
|
||||
|
||||
return (
|
||||
<MotionButtonBase
|
||||
aria-label={t("close")}
|
||||
className="absolute right-6 top-6 z-[99] flex size-8 items-center justify-center rounded-md duration-200 hover:bg-theme-button-hover"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<i className="i-mgc-close-cute-re block" />
|
||||
</MotionButtonBase>
|
||||
)
|
||||
}
|
||||
|
||||
const Content = () => {
|
||||
const key = useDeferredValue(useSettingTab() || "general")
|
||||
const { Component, loader } = pages[key]
|
||||
|
|
@ -75,7 +59,7 @@ const Content = () => {
|
|||
return (
|
||||
<Suspense>
|
||||
<SettingsTitle loader={loader} className="relative mb-0 px-8" />
|
||||
<Close />
|
||||
<ModalClose />
|
||||
<ScrollArea.ScrollArea
|
||||
mask={false}
|
||||
ref={setScroller}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { atom, useAtomValue, useSetAtom } from "jotai"
|
|||
import { createElement, useCallback } from "react"
|
||||
import { createContext, useContextSelector } from "use-context-selector"
|
||||
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { NoopChildren } from "~/components/ui/modal/stacked/utils"
|
||||
|
||||
import { SettingModalContent } from "./content"
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export const useSettingModal = () => {
|
|||
createElement(SettingModalContent, {
|
||||
initialTab,
|
||||
}),
|
||||
CustomModalComponent: NoopChildren,
|
||||
CustomModalComponent: PlainModal,
|
||||
modalContainerClassName: "overflow-hidden",
|
||||
}),
|
||||
[present],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Suspense, useCallback, useEffect, useRef } from "react"
|
|||
import { useUISettingSelector } from "~/atoms/settings/ui"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { Logo } from "~/components/icons/logo"
|
||||
import { LetsIconsResizeDownRightLight } from "~/components/icons/resize"
|
||||
import { useResizeableModal } from "~/components/ui/modal"
|
||||
import { preventDefault } from "~/lib/dom"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
|
@ -62,7 +63,7 @@ export function SettingModalLayout(
|
|||
scale: 0.96,
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex overflow-hidden rounded-xl border border-border",
|
||||
"relative flex overflow-hidden rounded-xl rounded-br-none border border-border",
|
||||
!overlay && "shadow-perfect",
|
||||
)}
|
||||
style={resizeableStyle}
|
||||
|
|
@ -97,8 +98,8 @@ export function SettingModalLayout(
|
|||
<div className="absolute inset-x-0 top-0 z-[1] h-8" onPointerDown={handleDrag} />
|
||||
)}
|
||||
<div className="flex h-0 flex-1 bg-theme-modal-background-opaque" ref={elementRef}>
|
||||
<div className="flex min-h-0 w-44 flex-col border-r px-2 py-6">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 px-2 font-bold">
|
||||
<div className="flex min-h-0 min-w-44 max-w-[20ch] flex-col border-r px-2 py-6">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 px-2 font-default font-bold">
|
||||
<Logo className="mr-1 size-6" />
|
||||
{APP_NAME}
|
||||
</div>
|
||||
|
|
@ -125,6 +126,8 @@ export function SettingModalLayout(
|
|||
<Suspense>{children}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LetsIconsResizeDownRightLight className="pointer-events-none absolute bottom-0 right-0 size-6 translate-x-px translate-y-px text-border" />
|
||||
</Resizable>
|
||||
</SettingContext.Provider>
|
||||
</m.div>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ export const SettingAppearance = () => {
|
|||
label: t("appearance.reduce_motion.label"),
|
||||
description: t("appearance.reduce_motion.description"),
|
||||
}),
|
||||
defineItem("usePointerCursor", {
|
||||
label: t("appearance.use_pointer_cursor.label"),
|
||||
description: t("appearance.use_pointer_cursor.description"),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { WEB_URL } from "@follow/shared/constants"
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { FeedIcon } from "~/components/feed-icon"
|
||||
import { Divider } from "~/components/ui/divider"
|
||||
import { LoadingCircle } from "~/components/ui/loading"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import {
|
||||
|
|
@ -25,6 +26,7 @@ export const SettingFeeds = () => {
|
|||
<div className="mb-4 space-y-2 text-sm">
|
||||
<p>{t("feeds.claimTips")}</p>
|
||||
</div>
|
||||
<Divider className="mb-6 mt-8" />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<ScrollArea.ScrollArea viewportClassName="max-h-[380px]">
|
||||
{claimedList.data?.length ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,491 @@
|
|||
import { WEB_URL } from "@follow/shared/constants"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
import { FeedCertification } from "~/components/feed-certification"
|
||||
import { FeedIcon } from "~/components/feed-icon"
|
||||
import type { Suggestion } from "~/components/ui/auto-completion"
|
||||
import { Autocomplete } from "~/components/ui/auto-completion"
|
||||
import { Avatar, AvatarImage } from "~/components/ui/avatar"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Divider } from "~/components/ui/divider"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { LoadingCircle } from "~/components/ui/loading"
|
||||
import { useModalStack } from "~/components/ui/modal"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table"
|
||||
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
import { views } from "~/constants"
|
||||
import { useAuthQuery, useI18n } from "~/hooks/common"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { cn, isBizId } from "~/lib/utils"
|
||||
import type { ListModel } from "~/models"
|
||||
import { ViewSelectorRadioGroup } from "~/modules/shared/ViewSelectorRadioGroup"
|
||||
import { Queries } from "~/queries"
|
||||
import { feedActions, getFeedById, useFeedById } from "~/store/feed"
|
||||
import { useSubscriptionStore } from "~/store/subscription"
|
||||
|
||||
export const SettingLists = () => {
|
||||
// const { t: appT } = useTranslation()
|
||||
const t = useI18n()
|
||||
// const { t } = useTranslation("settings")
|
||||
const listList = useAuthQuery(Queries.lists.list())
|
||||
|
||||
const { present } = useModalStack()
|
||||
|
||||
return (
|
||||
<section className="mt-4">
|
||||
<div className="mb-4 space-y-2 text-sm">
|
||||
<p>{t.settings("lists.info")}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
present({
|
||||
title: t.settings("lists.create"),
|
||||
content: ({ dismiss }) => <ListCreationModalContent dismiss={dismiss} />,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-add-cute-re mr-1 text-base" />
|
||||
{t.settings("lists.create")}
|
||||
</Button>
|
||||
<Divider className="mb-6 mt-8" />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<ScrollArea.ScrollArea viewportClassName="max-h-[380px]">
|
||||
{listList.data?.length ? (
|
||||
<Table className="mt-4">
|
||||
<TableHeader className="border-b">
|
||||
<TableRow className="[&_*]:!font-semibold">
|
||||
<TableHead size="sm">{t.settings("lists.title")}</TableHead>
|
||||
<TableHead className="w-36" size="sm">
|
||||
{t.settings("lists.view")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20" size="sm">
|
||||
{t.settings("lists.fee")}
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-20" size="sm">
|
||||
{t.common("words.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="border-t-[12px] border-transparent [&_td]:!px-3">
|
||||
{listList.data?.map((row) => (
|
||||
<TableRow key={row.title} className="h-8">
|
||||
<TableCell size="sm">
|
||||
<a
|
||||
target="_blank"
|
||||
href={`${WEB_URL}/list/${row.id}`}
|
||||
className="inline-flex items-center gap-2 font-semibold"
|
||||
>
|
||||
{row.image && (
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={row.image} />
|
||||
</Avatar>
|
||||
)}
|
||||
<span className="inline-block max-w-[200px] truncate">{row.title}</span>
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell align="center" size="sm">
|
||||
<div className="flex items-center gap-1">
|
||||
{t(views[row.view].name)}
|
||||
<span className={cn("inline-flex items-center", views[row.view].className)}>
|
||||
{views[row.view].icon}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center" size="sm">
|
||||
<div className="flex items-center gap-1">
|
||||
{row.fee}
|
||||
<i className="i-mgc-power shrink-0 text-lg text-accent" />
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell align="center" size="sm">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
present({
|
||||
title: t.settings("lists.feeds.manage"),
|
||||
content: () => <ListFeedsModalContent id={row.id} />,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-inbox-cute-re" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{t.common("words.manage")}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
present({
|
||||
title: t.settings("lists.edit"),
|
||||
content: ({ dismiss }) => (
|
||||
<ListCreationModalContent dismiss={dismiss} id={row.id} />
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-edit-cute-re" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{t.common("words.edit")}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : listList.isLoading ? (
|
||||
<LoadingCircle size="large" className="center absolute inset-0" />
|
||||
) : (
|
||||
<div className="mt-36 w-full text-center text-sm text-zinc-400">
|
||||
<p>{t.settings("lists.noLists")}</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea.ScrollArea>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
view: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
fee: z.number().min(0),
|
||||
})
|
||||
|
||||
const ListCreationModalContent = ({ dismiss, id }: { dismiss: () => void; id?: string }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const appT = useI18n()
|
||||
|
||||
const list = useFeedById(id) as ListModel
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
view: list?.view.toString() || views[0].view.toString(),
|
||||
fee: list?.fee || 0,
|
||||
title: list?.title || "",
|
||||
description: list?.description || "",
|
||||
image: list?.image || "",
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: z.infer<typeof formSchema>) => {
|
||||
if (id) {
|
||||
await apiClient.lists.$patch({
|
||||
json: {
|
||||
listId: id,
|
||||
...values,
|
||||
view: Number.parseInt(values.view),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await apiClient.lists.$post({
|
||||
json: {
|
||||
...values,
|
||||
view: Number.parseInt(values.view),
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t(id ? "lists.edit.success" : "lists.created.success"))
|
||||
Queries.lists.list().invalidate()
|
||||
dismiss()
|
||||
},
|
||||
async onError() {
|
||||
toast.error(t(id ? "lists.edit.error" : "lists.created.error"))
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
createMutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-[450px] space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div>
|
||||
<FormLabel>
|
||||
{t("lists.title")}
|
||||
<sup className="ml-1 align-sub text-red-500">*</sup>
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div>
|
||||
<FormLabel>{t("lists.description")}</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-4">
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>{t("lists.image")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input {...field} />
|
||||
{field.value && (
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage src={field.value} />
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="view"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("lists.view")}</FormLabel>
|
||||
|
||||
<ViewSelectorRadioGroup {...form.register("view")} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fee"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div>
|
||||
<FormLabel>{t("lists.fee")}</FormLabel>
|
||||
<FormDescription>{t("lists.fee.description")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
onChange={(value) => field.onChange(value.target.valueAsNumber)}
|
||||
/>
|
||||
<i className="i-mgc-power ml-4 shrink-0 text-xl text-accent" />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={createMutation.isPending}>
|
||||
{id ? appT.common("words.update") : appT.common("words.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const ListFeedsModalContent = ({ id }: { id: string }) => {
|
||||
const list = useFeedById(id) as ListModel
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const [feedSearchFor, setFeedSearchFor] = useState("")
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (values: string) => {
|
||||
const feed = await apiClient.lists.feeds.$post({
|
||||
json: {
|
||||
listId: id,
|
||||
feedId: values,
|
||||
},
|
||||
})
|
||||
feedActions.upsertMany([feed.data])
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.add.success"))
|
||||
Queries.lists.list().invalidate()
|
||||
},
|
||||
async onError() {
|
||||
toast.error(t("lists.feeds.add.error"))
|
||||
},
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async (feedId: string) => {
|
||||
await apiClient.lists.feeds.$delete({
|
||||
json: {
|
||||
listId: id,
|
||||
feedId,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.delete.success"))
|
||||
Queries.lists.list().invalidate()
|
||||
},
|
||||
async onError() {
|
||||
toast.error(t("lists.feeds.delete.error"))
|
||||
},
|
||||
})
|
||||
|
||||
const allFeeds = useSubscriptionStore((store) => {
|
||||
const feedInfo = [] as { title: string; id: string }[]
|
||||
|
||||
const allSubscriptions = Object.values(store.feedIdByView).flat()
|
||||
|
||||
for (const feedId of allSubscriptions) {
|
||||
const subscription = store.data[feedId]
|
||||
const feed = getFeedById(feedId)
|
||||
if (feed && feed.type === "feed") {
|
||||
feedInfo.push({ title: subscription.title || feed.title || "", id: feed.id })
|
||||
}
|
||||
}
|
||||
return feedInfo
|
||||
})
|
||||
|
||||
const autocompleteSuggestions: Suggestion[] = useMemo(() => {
|
||||
return allFeeds.map((feed) => ({
|
||||
name: feed.title,
|
||||
value: feed.id,
|
||||
}))
|
||||
}, [allFeeds])
|
||||
|
||||
const selectedFeedIdRef = useRef<string | null>()
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Autocomplete
|
||||
maxHeight={window.innerHeight < 600 ? 120 : 240}
|
||||
value={feedSearchFor}
|
||||
searchKeys={["name"]}
|
||||
onSuggestionSelected={(e) => {
|
||||
selectedFeedIdRef.current = e?.value
|
||||
setFeedSearchFor(e?.name || "")
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setFeedSearchFor(e.target.value)
|
||||
}}
|
||||
suggestions={autocompleteSuggestions}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isBizId(feedSearchFor)) {
|
||||
addMutation.mutate(feedSearchFor)
|
||||
return
|
||||
}
|
||||
if (selectedFeedIdRef.current) {
|
||||
addMutation.mutate(selectedFeedIdRef.current)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("lists.feeds.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<Divider className="mt-8" />
|
||||
<ScrollArea.ScrollArea viewportClassName="max-h-[380px] w-[450px]">
|
||||
<Table className="mt-4">
|
||||
<TableHeader className="border-b">
|
||||
<TableRow className="[&_*]:!font-semibold">
|
||||
{/* <TableHead className="w-20 text-center" size="sm">
|
||||
{t("lists.feeds.id")}
|
||||
</TableHead> */}
|
||||
<TableHead size="sm" className="pl-8">
|
||||
{t("lists.feeds.title")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20 text-center" size="sm">
|
||||
{t("lists.feeds.owner")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20 text-center" size="sm">
|
||||
{t("lists.feeds.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="border-t-[12px] border-transparent">
|
||||
{list.feeds?.map((row) => (
|
||||
<TableRow key={row.title} className="h-8">
|
||||
{/* <TableCell align="center" size="sm">
|
||||
{row.id}
|
||||
</TableCell> */}
|
||||
<TableCell size="sm">
|
||||
<a
|
||||
target="_blank"
|
||||
href={`${WEB_URL}/list/${row.id}`}
|
||||
className="flex items-center gap-2 font-semibold"
|
||||
>
|
||||
{row.siteUrl && <FeedIcon className="mr-0" siteUrl={row.siteUrl} />}
|
||||
<span className="inline-block max-w-[200px] truncate">{row.title}</span>
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell align="center" size="sm">
|
||||
<div className="center">
|
||||
<FeedCertification className="ml-0" feed={row} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center" size="sm">
|
||||
<Button variant="ghost" onClick={() => removeMutation.mutate(row.id)}>
|
||||
<i className="i-mgc-delete-2-cute-re" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea.ScrollArea>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -129,7 +129,6 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
|
|||
<FormLabel>{t("wallet.withdraw.amountLabel")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoFocus
|
||||
{...field}
|
||||
type="number"
|
||||
onChange={(value) => field.onChange(value.target.valueAsNumber)}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ const UserRenderer = ({
|
|||
onClick={() => {
|
||||
if (user?.id) presentUserModal(user.id)
|
||||
}}
|
||||
className={cn("flex items-center", user?.id ? "cursor-pointer" : "cursor-default")}
|
||||
className="flex cursor-button items-center"
|
||||
>
|
||||
{name === APP_NAME ? (
|
||||
<Logo className="aspect-square size-4" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { useLoaderData } from "react-router-dom"
|
||||
|
||||
import { EllipsisHorizontalTextWithTooltip } from "~/components/ui/typography"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
import { settings } from "./constants"
|
||||
|
|
@ -15,9 +16,9 @@ export const SettingsSidebarTitle = ({ path, className }: { path: string; classN
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-[0.94rem] font-medium", className)}>
|
||||
<i className={`${tab.iconName} text-[19px]`} />
|
||||
<span>{t(tab.name as any)}</span>
|
||||
<div className={cn("flex min-w-0 items-center gap-2 text-[0.94rem] font-medium", className)}>
|
||||
<i className={`${tab.iconName} shrink-0 text-[19px]`} />
|
||||
<EllipsisHorizontalTextWithTooltip>{t(tab.name as any)}</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { forwardRef } from "react"
|
||||
|
||||
import { Card, CardHeader } from "~/components/ui/card"
|
||||
import { views } from "~/constants"
|
||||
import { useI18n } from "~/hooks/common"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export const ViewSelectorRadioGroup = forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, ...rest }, ref) => {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className={cn("grid grid-cols-6 space-y-0 px-2 py-3", className)}>
|
||||
{views.map((view) => (
|
||||
<div key={view.name}>
|
||||
<input
|
||||
className="peer hidden"
|
||||
type="radio"
|
||||
id={view.name}
|
||||
value={view.view}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
<label
|
||||
htmlFor={view.name}
|
||||
className={cn(
|
||||
"hover:text-theme-foreground dark:hover:text-white",
|
||||
view.peerClassName,
|
||||
"center flex h-10 flex-col text-xs leading-none opacity-80 duration-200",
|
||||
"text-neutral-800 dark:text-zinc-200",
|
||||
"peer-checked:opacity-100",
|
||||
"whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{view.icon}</span>
|
||||
{t(view.name)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ import { usePresentFeedFormModal } from "~/hooks/biz/useFeedFormModal"
|
|||
import { useTitle } from "~/hooks/common"
|
||||
import { FeedViewType } from "~/lib/enum"
|
||||
import { cn } from "~/lib/utils"
|
||||
import type { FeedModel } from "~/models"
|
||||
import { ArticleItem } from "~/modules/entry-column/Items/article-item"
|
||||
import { NotificationItem } from "~/modules/entry-column/Items/notification-item"
|
||||
import { PictureItem } from "~/modules/entry-column/Items/picture-item"
|
||||
|
|
@ -31,6 +32,7 @@ export function Component() {
|
|||
const feed = useFeed({
|
||||
id,
|
||||
})
|
||||
const feedData = feed.data?.feed as FeedModel
|
||||
const entries = useEntriesPreview({
|
||||
id,
|
||||
})
|
||||
|
|
@ -93,11 +95,11 @@ export function Component() {
|
|||
})}
|
||||
</div>
|
||||
<span className="center mb-8 flex gap-4">
|
||||
{feed.data.feed.url.startsWith("https://") ? (
|
||||
{feedData.url.startsWith("https://") ? (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
window.open(feed.data.feed.url, "_blank")
|
||||
window.open(feedData.url, "_blank")
|
||||
}}
|
||||
>
|
||||
View Feed URL
|
||||
|
|
@ -107,7 +109,7 @@ export function Component() {
|
|||
variant={"outline"}
|
||||
onClick={() => {
|
||||
toast.success(t("copied_link"))
|
||||
navigator.clipboard.writeText(feed.data.feed.url)
|
||||
navigator.clipboard.writeText(feedData.url)
|
||||
}}
|
||||
>
|
||||
Copy Feed URL
|
||||
|
|
@ -141,10 +143,9 @@ export function Component() {
|
|||
<Item
|
||||
entryPreview={{
|
||||
entries: entry,
|
||||
// @ts-expect-error
|
||||
feeds: feed.data.feed as FeedModel,
|
||||
feeds: feedData,
|
||||
read: true,
|
||||
feedId: feed.data.feed.id!,
|
||||
feedId: feedData.id!,
|
||||
}}
|
||||
entryId={entry.id}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
import { PoweredByFooter } from "~/components/common/PoweredByFooter"
|
||||
import { FeedIcon } from "~/components/feed-icon"
|
||||
import { FollowIcon } from "~/components/icons/follow"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { LoadingCircle } from "~/components/ui/loading"
|
||||
import { usePresentFeedFormModal } from "~/hooks/biz/useFeedFormModal"
|
||||
import { useTitle } from "~/hooks/common"
|
||||
import type { ListModel } from "~/models"
|
||||
import { useFeed } from "~/queries/feed"
|
||||
|
||||
export function Component() {
|
||||
const { id } = useParams()
|
||||
|
||||
const feed = useFeed({
|
||||
id,
|
||||
isList: true,
|
||||
})
|
||||
const listData = feed.data?.feed as ListModel
|
||||
|
||||
const { t } = useTranslation("external")
|
||||
useTitle(feed.data?.feed.title)
|
||||
const presentFeedFormModal = usePresentFeedFormModal()
|
||||
return (
|
||||
<div className="center h-screen">
|
||||
{feed.isLoading ? (
|
||||
<LoadingCircle size="large" className="center fixed inset-0" />
|
||||
) : (
|
||||
feed.data?.feed && (
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center justify-center p-4 lg:p-0">
|
||||
<FeedIcon
|
||||
fallback
|
||||
feed={feed.data.feed}
|
||||
className="mask-squircle mask shrink-0"
|
||||
size={64}
|
||||
/>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
|
||||
<h1>{feed.data.feed.title}</h1>
|
||||
</div>
|
||||
<div className="mb-8 text-sm text-zinc-500">{feed.data.feed.description}</div>
|
||||
</div>
|
||||
<div className="mb-4 text-sm">
|
||||
{t("feed.followsAndFeeds", {
|
||||
subscriptionCount: feed.data.subscriptionCount,
|
||||
subscriptionNoun: t("feed.follower", { count: feed.data.subscriptionCount }),
|
||||
feedsCount: listData?.feeds?.length,
|
||||
feedsNoun: t("feed.feeds", { count: listData?.feeds?.length }),
|
||||
appName: APP_NAME,
|
||||
})}
|
||||
</div>
|
||||
<span className="center mb-8 flex gap-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
presentFeedFormModal(id!)
|
||||
}}
|
||||
>
|
||||
<FollowIcon className="mr-1 size-3" />
|
||||
{APP_NAME}
|
||||
</Button>
|
||||
</span>
|
||||
<PoweredByFooter className="mt-20 pb-12" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -70,50 +70,58 @@ export function Component() {
|
|||
<h3>{category}</h3>
|
||||
</div>
|
||||
<div>
|
||||
{subscriptions.data?.[category].map((subscription) => (
|
||||
<div key={subscription.feedId} className="group relative border-b py-5">
|
||||
<a
|
||||
className="flex flex-1 cursor-default"
|
||||
href={`/feed/${subscription.feedId}`}
|
||||
target="_blank"
|
||||
>
|
||||
<FeedIcon fallback feed={subscription.feeds} size={22} className="mr-3" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-0 flex-1 grow flex-col justify-center",
|
||||
"group-hover:grow-[0.85]",
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium leading-none">
|
||||
{subscription.feeds?.title}
|
||||
</div>
|
||||
{subscription.feeds?.description && (
|
||||
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
|
||||
{subscription.feeds.description}
|
||||
{subscriptions.data?.[category].map(
|
||||
(subscription) =>
|
||||
"feeds" in subscription && (
|
||||
<div key={subscription.feedId} className="group relative border-b py-5">
|
||||
<a
|
||||
className="flex flex-1 cursor-button"
|
||||
href={`/feed/${subscription.feedId}`}
|
||||
target="_blank"
|
||||
>
|
||||
<FeedIcon
|
||||
fallback
|
||||
feed={subscription.feeds}
|
||||
size={22}
|
||||
className="mr-3"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-0 flex-1 grow flex-col justify-center",
|
||||
"group-hover:grow-[0.85]",
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium leading-none">
|
||||
{subscription.feeds?.title}
|
||||
</div>
|
||||
{subscription.feeds?.description && (
|
||||
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
|
||||
{subscription.feeds.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
<span className="center absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
</a>
|
||||
<span className="center absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
presentFeedFormModal(subscription.feedId)
|
||||
}}
|
||||
>
|
||||
{isMe ? (
|
||||
t.common("words.edit")
|
||||
) : (
|
||||
<>
|
||||
<FollowIcon className="mr-1 size-3" />
|
||||
{APP_NAME}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
presentFeedFormModal(subscription.feedId)
|
||||
}}
|
||||
>
|
||||
{isMe ? (
|
||||
t.common("words.edit")
|
||||
) : (
|
||||
<>
|
||||
<FollowIcon className="mr-1 size-3" />
|
||||
{APP_NAME}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect } from "react"
|
||||
import type { DotLottie } from "@lottiefiles/dotlottie-react"
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react"
|
||||
import { m } from "framer-motion"
|
||||
import type { RefCallback } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
|
@ -17,10 +21,15 @@ import {
|
|||
FormMessage,
|
||||
} from "~/components/ui/form"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
import { SocialMediaLinks } from "~/constants/social"
|
||||
import { useSignOut } from "~/hooks/biz/useSignOut"
|
||||
import { getFetchErrorMessage } from "~/lib/error-parser"
|
||||
import confettiUrl from "~/lottie/confetti.lottie?url"
|
||||
import { useInvitationMutation } from "~/queries/invitations"
|
||||
|
||||
const absoluteConfettiUrl = new URL(confettiUrl, import.meta.url).href
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
})
|
||||
|
|
@ -42,15 +51,41 @@ export function Component() {
|
|||
invitationMutation.mutate(values.code)
|
||||
}
|
||||
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (invitationMutation.isSuccess) {
|
||||
setShowConfetti(true)
|
||||
}
|
||||
}, [invitationMutation.isSuccess])
|
||||
const [dotLottie, setDotLottie] = useState<DotLottie | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onComplete() {
|
||||
navigate("/")
|
||||
}
|
||||
}, [invitationMutation.isSuccess, navigate])
|
||||
|
||||
if (dotLottie) {
|
||||
dotLottie.addEventListener("complete", onComplete)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (dotLottie) {
|
||||
dotLottie.removeEventListener("complete", onComplete)
|
||||
}
|
||||
}
|
||||
}, [dotLottie, navigate])
|
||||
|
||||
const dotLottieRefCallback: RefCallback<DotLottie> = (dotLottie) => {
|
||||
setDotLottie(dotLottie)
|
||||
}
|
||||
|
||||
const signOut = useSignOut()
|
||||
|
||||
return (
|
||||
<div className="container flex h-screen w-full flex-col items-center justify-center gap-14">
|
||||
<Logo className="size-20" />
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-[512px] max-w-full">
|
||||
<FormField
|
||||
|
|
@ -68,14 +103,51 @@ export function Component() {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="center flex">
|
||||
<div className="center relative flex">
|
||||
{showConfetti && (
|
||||
<DotLottieReact
|
||||
className="absolute size-[120px]"
|
||||
src={absoluteConfettiUrl}
|
||||
loop={false}
|
||||
autoplay
|
||||
dotLottieRefCallback={dotLottieRefCallback}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant={showConfetti ? "outline" : "primary"}
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
isLoading={invitationMutation.isPending}
|
||||
>
|
||||
{showConfetti && (
|
||||
<m.i
|
||||
initial={{ y: 5 }}
|
||||
animate={{
|
||||
y: 0,
|
||||
}}
|
||||
className="i-mgc-check-circle-filled mr-2 text-green-500"
|
||||
/>
|
||||
)}
|
||||
{t("invitation.activate")}
|
||||
</Button>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
buttonClassName="absolute -right-2 -top-1 text-foreground/80"
|
||||
className="size-8 text-lg"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
signOut()
|
||||
window.location.href = "/"
|
||||
}}
|
||||
>
|
||||
<i className="i-mingcute-exit-door-line" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("login.signOut")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useParams } from "react-router-dom"
|
|||
import { ROUTE_ENTRY_PENDING, views } from "~/constants"
|
||||
import { useRouteView } from "~/hooks/biz/useRouteParams"
|
||||
import { EntryContent } from "~/modules/entry-content"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
||||
export const Component = () => {
|
||||
const { entryId } = useParams()
|
||||
|
|
@ -11,11 +12,13 @@ export const Component = () => {
|
|||
const inWideMode = view ? views[view].wideMode : false
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!inWideMode && (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<EntryContent entryId={entryId === ROUTE_ENTRY_PENDING ? "" : entryId} />
|
||||
</div>
|
||||
)}
|
||||
<AppLayoutGridContainerProvider>
|
||||
{!inWideMode && (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<EntryContent entryId={entryId === ROUTE_ENTRY_PENDING ? "" : entryId} />
|
||||
</div>
|
||||
)}
|
||||
</AppLayoutGridContainerProvider>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { useMemo, useRef } from "react"
|
|||
import { useResizable } from "react-resizable-layout"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
|
||||
import { getUISettings, setUISetting, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { PanelSplitter } from "~/components/ui/divider"
|
||||
import { views } from "~/constants"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { cn, isSafari } from "~/lib/utils"
|
||||
import { EntryColumn } from "~/modules/entry-column"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
||||
export function Component() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -17,11 +18,12 @@ export function Component() {
|
|||
const entryColWidth = useMemo(() => getUISettings().entryColWidth, [])
|
||||
const { view } = useRouteParams()
|
||||
const inWideMode = view ? views[view].wideMode : false
|
||||
const feedColumnWidth = useUISettingKey("feedColWidth")
|
||||
const { position, separatorProps, isDragging, separatorCursor } = useResizable({
|
||||
axis: "x",
|
||||
// FIXME: Less than this width causes grid images to overflow on safari
|
||||
min: isSafari() ? 356 : 300,
|
||||
max: 450,
|
||||
max: Math.max((window.innerWidth - feedColumnWidth) / 2, 600),
|
||||
initial: entryColWidth,
|
||||
containerRef,
|
||||
onResizeEnd({ position }) {
|
||||
|
|
@ -37,7 +39,9 @@ export function Component() {
|
|||
width: position,
|
||||
}}
|
||||
>
|
||||
<EntryColumn />
|
||||
<AppLayoutGridContainerProvider>
|
||||
<EntryColumn />
|
||||
</AppLayoutGridContainerProvider>
|
||||
</div>
|
||||
{!inWideMode && (
|
||||
<PanelSplitter {...separatorProps} cursor={separatorCursor} isDragging={isDragging} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { throttle } from "lodash-es"
|
|||
import type { PropsWithChildren } from "react"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { useResizable } from "react-resizable-layout"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
|
|
@ -15,12 +15,14 @@ import { useLoginModalShow, useWhoami } from "~/atoms/user"
|
|||
import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
|
||||
import { ErrorComponentType } from "~/components/errors/enum"
|
||||
import { PanelSplitter } from "~/components/ui/divider"
|
||||
import { Kbd } from "~/components/ui/kbd/Kbd"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
|
||||
import { NoopChildren } from "~/components/ui/modal/stacked/utils"
|
||||
import { RootPortal } from "~/components/ui/portal"
|
||||
import { HotKeyScopeMap } from "~/constants"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useDailyTask } from "~/hooks/biz/useDailyTask"
|
||||
import { useI18n } from "~/hooks/common"
|
||||
import { preventDefault } from "~/lib/dom"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator"
|
||||
|
|
@ -33,6 +35,7 @@ import { useShortcutsModal } from "~/modules/modal/shortcuts"
|
|||
import { CmdF } from "~/modules/panel/cmdf"
|
||||
import { SearchCmdK } from "~/modules/panel/cmdk"
|
||||
import { CmdNTrigger } from "~/modules/panel/cmdn"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
||||
const FooterInfo = () => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -53,7 +56,7 @@ const FooterInfo = () => {
|
|||
onClick={() => {
|
||||
window.open(`${repository.url}/releases`)
|
||||
}}
|
||||
className="center cursor-pointer rounded-full border bg-background p-1.5 shadow-sm"
|
||||
className="center rounded-full border bg-background p-1.5 shadow-sm"
|
||||
>
|
||||
<i className="i-mgc-download-2-cute-re size-3.5 opacity-80" />
|
||||
</button>
|
||||
|
|
@ -84,16 +87,19 @@ export function Component() {
|
|||
>
|
||||
{!import.meta.env.PROD && <EnvironmentIndicator />}
|
||||
|
||||
<FeedResponsiveResizerContainer containerRef={containerRef}>
|
||||
<FeedColumn>
|
||||
<CornerPlayer />
|
||||
<FooterInfo />
|
||||
<AppLayoutGridContainerProvider>
|
||||
<FeedResponsiveResizerContainer containerRef={containerRef}>
|
||||
<FeedColumn>
|
||||
<CornerPlayer />
|
||||
<FooterInfo />
|
||||
|
||||
{ELECTRON && <AutoUpdater />}
|
||||
{ELECTRON && <AutoUpdater />}
|
||||
|
||||
<NetworkStatusIndicator />
|
||||
</FeedColumn>
|
||||
</FeedResponsiveResizerContainer>
|
||||
</AppLayoutGridContainerProvider>
|
||||
|
||||
<NetworkStatusIndicator />
|
||||
</FeedColumn>
|
||||
</FeedResponsiveResizerContainer>
|
||||
<main
|
||||
ref={setMainContainerElement}
|
||||
className="flex min-w-0 flex-1 bg-theme-background pt-[calc(var(--fo-window-padding-top)_-10px)] !outline-none"
|
||||
|
|
@ -113,7 +119,7 @@ export function Component() {
|
|||
<RootPortal>
|
||||
<DeclarativeModal
|
||||
id="login"
|
||||
CustomModalComponent={NoopChildren}
|
||||
CustomModalComponent={PlainModal}
|
||||
open
|
||||
title="Login"
|
||||
canClose={false}
|
||||
|
|
@ -196,13 +202,14 @@ const FeedResponsiveResizerContainer = ({
|
|||
timer = clearTimeout(timer)
|
||||
}
|
||||
}, [feedColumnShow])
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 overflow-hidden",
|
||||
"absolute inset-y-0 z-10",
|
||||
"absolute inset-y-0 z-[2]",
|
||||
feedColumnTempShow && !feedColumnShow && "shadow-drawer-right z-[12] border-r",
|
||||
!feedColumnShow && !feedColumnTempShow ? "-translate-x-full delay-200" : "",
|
||||
!isDragging ? "duration-200" : "",
|
||||
|
|
@ -213,9 +220,6 @@ const FeedResponsiveResizerContainer = ({
|
|||
"--fo-feed-col-w": `${position}px`,
|
||||
}}
|
||||
>
|
||||
{/* {React.cloneElement(children, {
|
||||
className: "!bg-native",
|
||||
})} */}
|
||||
<Slot className={!feedColumnShow ? "!bg-native" : ""}>{children}</Slot>
|
||||
</div>
|
||||
|
||||
|
|
@ -227,7 +231,34 @@ const FeedResponsiveResizerContainer = ({
|
|||
/>
|
||||
|
||||
{delayShowSplitter && (
|
||||
<PanelSplitter isDragging={isDragging} cursor={separatorCursor} {...separatorProps} />
|
||||
<PanelSplitter
|
||||
isDragging={isDragging}
|
||||
cursor={separatorCursor}
|
||||
{...separatorProps}
|
||||
onDoubleClick={() => {
|
||||
setFeedColumnShow(false)
|
||||
}}
|
||||
tooltip={
|
||||
!isDragging && (
|
||||
<>
|
||||
<div>
|
||||
{/* <b>Drag</b> to resize */}
|
||||
<Trans t={t} i18nKey="resize.tooltip.drag_to_resize" components={{ b: <b /> }} />
|
||||
</div>
|
||||
<div className="center">
|
||||
<span>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="resize.tooltip.double_click_to_collapse"
|
||||
components={{ b: <b /> }}
|
||||
/>
|
||||
</span>{" "}
|
||||
<Kbd className="ml-1">{"["}</Kbd>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { SettingsTitle } from "~/modules/settings/title"
|
|||
import { defineSettingPageData } from "~/modules/settings/utils"
|
||||
|
||||
const iconName = "i-mgc-certificate-cute-re"
|
||||
const priority = 1053
|
||||
const priority = 1060
|
||||
|
||||
export const loader = defineSettingPageData({
|
||||
iconName,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue