Merge branch 'dev' into ab

This commit is contained in:
Stephen Zhou 2024-09-22 16:34:02 +08:00 committed by GitHub
commit 65e69cd40a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
150 changed files with 4922 additions and 3465 deletions

133
.github/workflows/nightly.yml vendored Normal file
View File

@ -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 }}

View File

@ -1,2 +1,4 @@
packages/shared/src/hono.ts
pnpm-lock.yaml
CHANGELOG.md

File diff suppressed because it is too large Load Diff

View File

@ -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).

View File

@ -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"
}
}

View File

@ -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}`])
}
}

View File

@ -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
}

View File

@ -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 }) },
],
},

View File

@ -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 {

View File

@ -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",

View File

@ -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")],

View File

@ -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"

View File

@ -19,6 +19,7 @@ export const createDefaultSettings = (): UISettings => ({
modalDraggable: true,
modalOpaque: true,
reduceMotion: false,
usePointerCursor: false,
// Font
uiFontFamily: "SN Pro",

View File

@ -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

View File

@ -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()

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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])

View File

@ -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>
)
})

View File

@ -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",
],

View File

@ -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>

View File

@ -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,
)}

View File

@ -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>
)
}

View File

@ -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}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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}

View File

@ -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,
)}

View File

@ -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,
})
},

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -1,3 +0,0 @@
import type { PropsWithChildren } from "react"
export const NoopChildren = ({ children }: PropsWithChildren) => children

View File

@ -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>

View File

@ -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}

View File

@ -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

View File

@ -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>
)
}

View File

@ -18,7 +18,7 @@ export const shortcuts = {
layout: {
toggleSidebar: {
name: "keys.layout.toggleSidebar",
key: "Meta+B",
key: "Meta+B, [",
},
showShortcuts: {
name: "keys.layout.showShortcuts",

View File

@ -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 }

View File

@ -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: () => {

View File

@ -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,

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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}%)`,
}
}

View File

@ -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 {

View File

@ -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)
})
})

View File

@ -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.

View File

@ -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<

View File

@ -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>
)
}

View File

@ -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,

View File

@ -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>
)
}

View File

@ -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,

View File

@ -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>

View File

@ -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(),
}}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
})
}

View File

@ -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])
}

View File

@ -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)

View File

@ -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}

View File

@ -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",
)}

View File

@ -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?: {

View File

@ -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>
)
}

View File

@ -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">

View File

@ -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" },

View File

@ -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
}

View File

@ -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()
}}
>

View File

@ -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>
</>
)

View File

@ -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" />

View File

@ -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>
)
}

View File

@ -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])

View File

@ -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()}
/>
))}

View File

@ -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,

View File

@ -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",

View File

@ -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>
)}
/>

View File

@ -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"
>

View File

@ -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>
)
}

View File

@ -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}

View File

@ -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],

View File

@ -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>

View File

@ -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>

View File

@ -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 ? (

View File

@ -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>
</>
)
}

View File

@ -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)}

View File

@ -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" />

View File

@ -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>
)
}

View File

@ -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>
)
})

View File

@ -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}
/>

View File

@ -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>
)
}

View File

@ -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>
))

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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} />

View File

@ -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>
</>
)
}
/>
)}
</>
)

View File

@ -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