diff --git a/.gitignore b/.gitignore index 69c5ca4..fa747bf 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ next-env.d.ts /.hermes/ AGENTS.md -CLAUDE.md \ No newline at end of file +CLAUDE.md + +.vscode \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3b830f4..1861874 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,6 @@ USER node EXPOSE 22786 HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -q --spider http://127.0.0.1:22786/ || exit 1 + CMD wget -q --spider http://127.0.0.1:22786/login || exit 1 CMD ["node", "server.js"] diff --git a/package.json b/package.json index 0233d40..0408455 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@base-ui/react": "^1.8.0", "@tanstack/react-query": "^5.103.2", + "@tanstack/react-virtual": "^3.14.13", "axios": "^1.20.0", "class-variance-authority": "^0.7.1", "cn": "^0.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95cdd5e..9c7bab2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@tanstack/react-query': specifier: ^5.103.2 version: 5.103.2(react@19.2.8) + '@tanstack/react-virtual': + specifier: ^3.14.13 + version: 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8) axios: specifier: ^1.20.0 version: 1.20.0 @@ -834,6 +837,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.14.13': + resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.11': + resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==} + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -3933,6 +3945,14 @@ snapshots: '@tanstack/query-core': 5.103.2 react: 19.2.8 + '@tanstack/react-virtual@3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/virtual-core': 3.17.11 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/virtual-core@3.17.11': {} + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 diff --git a/src/app/(app)/components/app-header.tsx b/src/app/(app)/components/app-header.tsx new file mode 100644 index 0000000..95a63a8 --- /dev/null +++ b/src/app/(app)/components/app-header.tsx @@ -0,0 +1,18 @@ +import { BrandMark } from "@/components/brand-mark"; +import { Separator } from "@/components/ui/separator"; + +import { LogoutButton } from "./logout-button"; +import { PlaylistMenu } from "./playlist-menu"; + +export const AppHeader = ({ token }: { token: string }) => ( +
+
+ +
+ + + +
+
+
+); diff --git a/src/app/(app)/components/file-card.tsx b/src/app/(app)/components/file-card.tsx new file mode 100644 index 0000000..0d349c3 --- /dev/null +++ b/src/app/(app)/components/file-card.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; +import { CopyIcon, DownloadIcon, EyeIcon, FileIcon, ImageOffIcon, PlayIcon } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useCopy } from "@/hooks/use-copy"; +import type { LibraryFileView } from "@/hooks/use-library"; +import { useOrigin } from "@/hooks/use-origin"; +import { formatBytes } from "@/utils/format"; +import { buildStreamUrl, toPotPlayerUrl } from "@/utils/media"; + +const ExtLabel = ({ ext }: { ext: string }) => ( + {ext || "file"} +); + +/** 无法预览时的占位封面 */ +const PlaceholderCover = ({ ext, icon: Icon }: { ext: string; icon: typeof FileIcon }) => ( +
+
+); + +export const FileCard = ({ file, token }: { file: LibraryFileView; token: string }) => { + const origin = useOrigin(); + const copy = useCopy(); + const [previewOpen, setPreviewOpen] = useState(false); + const [imageFailed, setImageFailed] = useState(false); + + if (!origin) return null; + + const streamUrl = buildStreamUrl(origin, file.playUrl, file.displayName, token); + const potPlayerUrl = toPotPlayerUrl(streamUrl); + + let cover: React.ReactNode; + let primaryAction: React.ReactNode; + + if (file.kind === "video") { + cover = ( + + + + + + + + ); + primaryAction = ( + + ); + } else if (file.kind === "image") { + cover = imageFailed ? ( + + ) : ( + + ); + primaryAction = ( + + ); + } else { + cover = ; + primaryAction = ( + + ); + } + + return ( + + {cover} + +

+ {file.displayName} +

+

+ · {formatBytes(file.size)} +

+
+ + {primaryAction} + + copy(streamUrl, "已复制直链")} + /> + } + > + + + 复制直链 + + + + {file.kind === "image" && ( + + + + {file.displayName} + + {/* eslint-disable-next-line @next/next/no-img-element */} + {file.displayName} + + + )} +
+ ); +}; diff --git a/src/app/(app)/components/file-grid.tsx b/src/app/(app)/components/file-grid.tsx new file mode 100644 index 0000000..6f54dcb --- /dev/null +++ b/src/app/(app)/components/file-grid.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; + +const COLUMN_CLASS: Record = { + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", + 5: "grid-cols-5" +}; + +/** + * 等宽卡片网格:规范中唯一允许的 grid 布局,其余一律 flex;最多 5 列。 + * 传入 columns 时使用固定列数(虚拟列表按容器宽度计算),否则按视口断点 2→3→4→5 列。 + */ +export const FileGrid = ({ columns, children }: { columns?: number; children: React.ReactNode }) => ( +
+ {children} +
+); diff --git a/src/app/(app)/components/item-header.tsx b/src/app/(app)/components/item-header.tsx new file mode 100644 index 0000000..27978e3 --- /dev/null +++ b/src/app/(app)/components/item-header.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { ChevronRightIcon } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { SOURCE_LABELS } from "@/constants/torbox"; +import type { LibraryItemView } from "@/hooks/use-library"; +import { formatBytes, formatCount } from "@/utils/format"; + +import { SourceDot } from "./source-dot"; + +type ItemHeaderProps = { + item: LibraryItemView; + expanded: boolean; + onToggle: () => void; +}; + +/** 资源分组标题行:虚拟列表中标题与卡片行相互独立,折叠状态由外部控制 */ +export const ItemHeader = ({ item, expanded, onToggle }: ItemHeaderProps) => ( +
+ + + + {SOURCE_LABELS[item.source]} + +

+ {item.name} +

+ + {formatCount(item.files.length)} 文件 + · {formatBytes(item.totalSize)} + +
+); diff --git a/src/app/(app)/components/library-list.tsx b/src/app/(app)/components/library-list.tsx new file mode 100644 index 0000000..c26d9d2 --- /dev/null +++ b/src/app/(app)/components/library-list.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual"; +import { TriangleAlertIcon } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { SOURCE_LABELS } from "@/constants/torbox"; +import { useElementWidth } from "@/hooks/use-element-width"; +import type { LibraryItemView } from "@/hooks/use-library"; +import { cn } from "@/lib/utils"; +import { useLibraryFilterStore } from "@/stores/library-filter-store"; +import { useLibraryViewStore, type LibraryScrollSnapshot } from "@/stores/library-view-store"; +import type { SourceType } from "@/types/torbox"; + +import { FileCard } from "./file-card"; +import { FileGrid } from "./file-grid"; +import { ItemHeader } from "./item-header"; +import { + GRID_GAP, + buildLibraryRows, + estimateRowSize, + getColumnCount, + type LibraryRow +} from "./library-rows"; + +const PADDING_START = 20; +const PADDING_END = 32; +/** 卡片行较高,上下各多渲染 3 行即可覆盖快速滚动 */ +const OVERSCAN = 3; + +type RowVirtualizer = Virtualizer; + +type LibraryListProps = { + /** 产品 UI 持有的滚动容器(header 下方的内容区) */ + scrollRef: React.RefObject; + items: LibraryItemView[]; + notices: [SourceType, string][]; + token: string; +}; + +/** 记录首个可见行与偏移,供刷新页面后按锚点恢复 */ +const takeScrollSnapshot = ( + instance: RowVirtualizer, + signature: string +): LibraryScrollSnapshot | null => { + const offset = instance.scrollOffset ?? 0; + const anchor = instance.getVirtualItemForOffset(offset); + if (!anchor) return null; + return { + signature, + anchorKey: String(anchor.key), + anchorDelta: offset - anchor.start, + measurements: instance.takeSnapshot() + }; +}; + +/** 按锚点行在当前数据中的位置换算恢复偏移;锚点已不存在则回到顶部 */ +const getRestoreOffset = ( + rows: LibraryRow[], + snapshot: LibraryScrollSnapshot, + estimate: (index: number) => number +) => { + const sizes = new Map(snapshot.measurements.map((item) => [String(item.key), item.size])); + let offset = PADDING_START; + for (let index = 0; index < rows.length; index += 1) { + if (rows[index].key === snapshot.anchorKey) return Math.max(0, offset + snapshot.anchorDelta); + offset += sizes.get(rows[index].key) ?? estimate(index); + } + return 0; +}; + +/** 行自身的留白计入测量高度,因此虚拟器 gap 为 0 */ +const rowClassName = (row: LibraryRow) => { + if (row.type === "notice") return "pb-2"; + if (row.type === "header") return cn("pb-3", !row.first && "pt-5"); + return "pb-3"; +}; + +export const LibraryList = (props: LibraryListProps) => { + const [contentRef, width] = useElementWidth(); + + return ( +
+ {/* 先拿到容器宽度再创建虚拟器,保证首帧列数与快照恢复一致 */} + {width > 0 && } +
+ ); +}; + +const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListProps & { width: number }) => { + const columns = getColumnCount(width); + const cardWidth = (width - GRID_GAP * (columns - 1)) / columns; + + const collapsed = useLibraryViewStore((state) => state.collapsed); + const toggleCollapsed = useLibraryViewStore((state) => state.toggleCollapsed); + const saveSnapshot = useLibraryViewStore((state) => state.saveSnapshot); + const signature = useLibraryFilterStore((state) => + JSON.stringify([state.keyword.trim(), state.source, state.kind]) + ); + + const rows = useMemo( + () => buildLibraryRows(items, notices, columns, new Set(collapsed)), + [items, notices, columns, collapsed] + ); + const getItemKey = useCallback((index: number) => rows[index].key, [rows]); + const estimateSize = useCallback( + (index: number) => estimateRowSize(rows[index], cardWidth), + [rows, cardWidth] + ); + + // 只在挂载时读取一次快照,筛选条件一致才恢复 + const [restore] = useState(() => { + const snapshot = useLibraryViewStore.getState().snapshot; + return snapshot?.signature === signature ? snapshot : null; + }); + // 停在顶部时不锚定,刷新出的新资源直接出现在顶部;向下浏览时锚定当前行,避免内容跳动 + const [atTop, setAtTop] = useState(restore === null); + const wasScrolling = useRef(false); + + // eslint-disable-next-line react-hooks/incompatible-library -- 项目未启用 React Compiler;虚拟器每次渲染都需读取最新滚动状态 + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + getItemKey, + estimateSize, + overscan: OVERSCAN, + paddingStart: PADDING_START, + paddingEnd: PADDING_END, + anchorTo: atTop ? "start" : "end", + initialMeasurementsCache: restore?.measurements, + initialOffset: () => (restore ? getRestoreOffset(rows, restore, estimateSize) : 0), + onChange: (instance) => { + setAtTop((instance.scrollOffset ?? 0) <= 1); + // 每次滚动停止时保存快照 + if (wasScrolling.current && !instance.isScrolling) { + const snapshot = takeScrollSnapshot(instance, signature); + if (snapshot) saveSnapshot(snapshot); + } + wasScrolling.current = instance.isScrolling; + } + }); + + // 筛选条件变化时回到顶部(首次挂载不处理,以免覆盖快照恢复) + const lastSignature = useRef(signature); + useLayoutEffect(() => { + if (lastSignature.current === signature) return; + lastSignature.current = signature; + virtualizer.scrollToOffset(0); + }, [signature, virtualizer]); + + // 卸载时(如列表被空状态替换)补存一次快照 + useEffect( + () => () => { + const snapshot = takeScrollSnapshot(virtualizer, lastSignature.current); + if (snapshot) saveSnapshot(snapshot); + }, + [virtualizer, saveSnapshot] + ); + + const virtualRows = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const first = virtualRows[0]; + const last = virtualRows[virtualRows.length - 1]; + + return ( +
+ {/* 不使用定位:可见行处于正常文档流,上下用占位块撑出未渲染部分的高度 */} +