diff --git a/package.json b/package.json index 0408455..0fd277c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "class-variance-authority": "^0.7.1", "cn": "^0.3.2", "lucide-react": "^1.47.0", + "motion": "^14.0.0", "next": "16.3.5", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c7bab2..bd55c49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: lucide-react: specifier: ^1.47.0 version: 1.47.0(react@19.2.8) + motion: + specifier: ^14.0.0 + version: 14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next: specifier: 16.3.5 version: 16.3.5(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1679,6 +1682,17 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@14.0.0: + resolution: {integrity: sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2338,6 +2352,23 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + motion-dom@14.0.0: + resolution: {integrity: sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==} + + motion-utils@14.0.0: + resolution: {integrity: sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==} + + motion@14.0.0: + resolution: {integrity: sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5002,6 +5033,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 14.0.0 + motion-utils: 14.0.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + fresh@2.0.0: {} fs-extra@11.4.0: @@ -5575,6 +5615,20 @@ snapshots: minipass@7.1.3: {} + motion-dom@14.0.0: + dependencies: + motion-utils: 14.0.0 + + motion-utils@14.0.0: {} + + motion@14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + ms@2.1.3: {} nanoid@3.3.19: {} diff --git a/src/app/(app)/components/app-header.tsx b/src/app/(app)/components/app-header.tsx index 95a63a8..f55a5fd 100644 --- a/src/app/(app)/components/app-header.tsx +++ b/src/app/(app)/components/app-header.tsx @@ -1,3 +1,4 @@ +// 应用顶栏 import { BrandMark } from "@/components/brand-mark"; import { Separator } from "@/components/ui/separator"; diff --git a/src/app/(app)/components/expiry-badge.tsx b/src/app/(app)/components/expiry-badge.tsx new file mode 100644 index 0000000..749157c --- /dev/null +++ b/src/app/(app)/components/expiry-badge.tsx @@ -0,0 +1,34 @@ +// 资源即将过期徽标 +import { ClockAlertIcon } from "lucide-react"; + +import { EXPIRING_URGENT_MS, EXPIRING_WINDOW_MS } from "@/constants/torbox"; +import type { LibraryItemView } from "@/hooks/use-library"; +import { cn } from "@/lib/utils"; +import { formatDateTime, formatDuration } from "@/utils/date"; + +/** + * 快过期提示:距过期不足 EXPIRING_WINDOW_MS 时显示剩余时间, + * 不足 EXPIRING_URGENT_MS 或已过期时转为红色;文件已不可用时由下载状态说明,不再重复提示。 + */ +export const ExpiryBadge = ({ item }: { item: LibraryItemView }) => { + const { expiresInMs, expiresAt, status } = item; + if (expiresInMs === null || !expiresAt || status === "unavailable") + return null; + if (expiresInMs > EXPIRING_WINDOW_MS) return null; + + const expired = expiresInMs <= 0; + const urgent = expired || expiresInMs <= EXPIRING_URGENT_MS; + + return ( + + + ); +}; diff --git a/src/app/(app)/components/file-card.tsx b/src/app/(app)/components/file-card.tsx index 0d349c3..843a3bc 100644 --- a/src/app/(app)/components/file-card.tsx +++ b/src/app/(app)/components/file-card.tsx @@ -1,32 +1,113 @@ +// 文件卡片 "use client"; import { useState } from "react"; -import { CopyIcon, DownloadIcon, EyeIcon, FileIcon, ImageOffIcon, PlayIcon } from "lucide-react"; +import { motion } from "motion/react"; +import { + CircleOffIcon, + CopyIcon, + DownloadIcon, + EyeIcon, + FileIcon, + HourglassIcon, + ImageOffIcon, + PlayIcon, + type LucideIcon +} 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 { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@/components/ui/tooltip"; +import { SPOTLIGHT } from "@/constants/motion"; import { useCopy } from "@/hooks/use-copy"; import type { LibraryFileView } from "@/hooks/use-library"; import { useOrigin } from "@/hooks/use-origin"; +import { useSpotlight } from "@/hooks/use-spotlight"; import { formatBytes } from "@/utils/format"; -import { buildStreamUrl, toPotPlayerUrl } from "@/utils/media"; +import type { DownloadStatus } from "@/types/torbox"; +import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media"; + +import { CARD_FOCUS_PROPS } from "./library-dom"; const ExtLabel = ({ ext }: { ext: string }) => ( {ext || "file"} ); /** 无法预览时的占位封面 */ -const PlaceholderCover = ({ ext, icon: Icon }: { ext: string; icon: typeof FileIcon }) => ( +const PlaceholderCover = ({ + ext, + icon: Icon +}: { + ext: string; + icon: typeof FileIcon; +}) => (
); -export const FileCard = ({ file, token }: { file: LibraryFileView; token: string }) => { +/** + * 视频封面:点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。 + * 两层同格叠放、内边距放在内容层,保证点阵对齐。 + */ +const VideoCover = ({ ext, href }: { ext: string; href: string }) => { + const { maskImage, opacity, handlers } = useSpotlight(SPOTLIGHT); + + return ( + + + ); +}; + +/** 资源未就绪时,主操作按钮保持原样式但禁用(仍可被键盘聚焦,方向键导航不中断) */ +const PENDING_ACTION: Record< + MediaKind, + { icon: LucideIcon; label: string; variant: "default" | "secondary" } +> = { + video: { icon: PlayIcon, label: "PotPlayer", variant: "default" }, + image: { icon: EyeIcon, label: "预览", variant: "secondary" }, + other: { icon: DownloadIcon, label: "下载", variant: "secondary" } +}; + +type FileCardProps = { + file: LibraryFileView; + token: string; + /** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */ + status: DownloadStatus; +}; + +export const FileCard = ({ file, token, status }: FileCardProps) => { const origin = useOrigin(); const copy = useCopy(); const [previewOpen, setPreviewOpen] = useState(false); @@ -34,30 +115,54 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string if (!origin) return null; - const streamUrl = buildStreamUrl(origin, file.playUrl, file.displayName, token); + const streamUrl = buildStreamUrl( + origin, + file.playUrl, + file.displayName, + token + ); const potPlayerUrl = toPotPlayerUrl(streamUrl); + const available = status === "ready"; let cover: React.ReactNode; let primaryAction: React.ReactNode; - if (file.kind === "video") { + if (!available) { + const action = PENDING_ACTION[file.kind]; cover = ( - - - - - - - + ); primaryAction = ( - + ); + } else if (file.kind === "video") { + cover = ; + primaryAction = ( + @@ -83,7 +188,13 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string ); primaryAction = ( - @@ -97,6 +208,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string className="flex-1" render={} nativeButton={false} + {...CARD_FOCUS_PROPS} > 下载 @@ -105,7 +217,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string } return ( - + {cover}

@@ -124,6 +236,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string variant="ghost" size="icon-sm" aria-label="复制直链" + disabled={!available} onClick={() => copy(streamUrl, "已复制直链")} /> } @@ -134,11 +247,13 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string - {file.kind === "image" && ( + {available && file.kind === "image" && (

- {file.displayName} + + {file.displayName} + {/* eslint-disable-next-line @next/next/no-img-element */} = { @@ -7,17 +13,51 @@ const COLUMN_CLASS: Record = { 5: "grid-cols-5" }; +const gridVariants: Variants = { + hidden: {}, + visible: { transition: { staggerChildren: CARD_ENTER.stagger } } +}; + +const itemVariants: Variants = { + hidden: { opacity: 0, y: CARD_ENTER.offsetY }, + visible: { opacity: 1, y: 0, transition: CARD_ENTER.transition } +}; + +type FileGridProps = { + columns?: number; + /** 挂载时子项按顺序依次浮现,子项需为 FileGridItem */ + animated?: boolean; + children: React.ReactNode; +}; + /** * 等宽卡片网格:规范中唯一允许的 grid 布局,其余一律 flex;最多 5 列。 * 传入 columns 时使用固定列数(虚拟列表按容器宽度计算),否则按视口断点 2→3→4→5 列。 */ -export const FileGrid = ({ columns, children }: { columns?: number; children: React.ReactNode }) => ( -
( + {children} -
+ +); + +/** 网格单元:继承 FileGrid 的入场编排,透传 data-* 等属性 */ +export const FileGridItem = ({ + className, + ...props +}: HTMLMotionProps<"div">) => ( + ); diff --git a/src/app/(app)/components/item-download-status.tsx b/src/app/(app)/components/item-download-status.tsx new file mode 100644 index 0000000..9c59a9f --- /dev/null +++ b/src/app/(app)/components/item-download-status.tsx @@ -0,0 +1,41 @@ +// 资源下载状态与进度展示 +import { CircleOffIcon } from "lucide-react"; + +import { Progress } from "@/components/ui/progress"; +import { DOWNLOAD_STATE_LABELS } from "@/constants/torbox"; +import type { LibraryItemView } from "@/hooks/use-library"; + +/** 资源未就绪时在标题行显示下载进度或失效提示;就绪时不渲染 */ +export const ItemDownloadStatus = ({ item }: { item: LibraryItemView }) => { + if (item.status === "ready") return null; + + if (item.status === "unavailable") { + return ( + + + ); + } + + const percent = Math.floor(item.progress * 100); + const label = DOWNLOAD_STATE_LABELS[item.downloadState ?? ""] ?? "处理中"; + + return ( + + + {percent}% + {label} + + ); +}; diff --git a/src/app/(app)/components/item-header.tsx b/src/app/(app)/components/item-header.tsx index 27978e3..e3a3871 100644 --- a/src/app/(app)/components/item-header.tsx +++ b/src/app/(app)/components/item-header.tsx @@ -1,3 +1,4 @@ +// 资源条目标题行 "use client"; import { ChevronRightIcon } from "lucide-react"; @@ -8,6 +9,9 @@ import { SOURCE_LABELS } from "@/constants/torbox"; import type { LibraryItemView } from "@/hooks/use-library"; import { formatBytes, formatCount } from "@/utils/format"; +import { ExpiryBadge } from "./expiry-badge"; +import { ItemDownloadStatus } from "./item-download-status"; +import { NewBadge } from "./new-badge"; import { SourceDot } from "./source-dot"; type ItemHeaderProps = { @@ -34,9 +38,12 @@ export const ItemHeader = ({ item, expanded, onToggle }: ItemHeaderProps) => ( {SOURCE_LABELS[item.source]} + {item.isNew && }

{item.name}

+ + {formatCount(item.files.length)} 文件 · {formatBytes(item.totalSize)} diff --git a/src/app/(app)/components/library-dom.ts b/src/app/(app)/components/library-dom.ts new file mode 100644 index 0000000..eb03812 --- /dev/null +++ b/src/app/(app)/components/library-dom.ts @@ -0,0 +1,15 @@ +// 资源列表 DOM 标记约定(data 属性与选择器) + +/** + * 资源列表的 DOM 约定:交互 hook 通过这些 data 属性识别目标, + * 组件只负责打标记(展开 *_PROPS),互不引用。 + */ +export const CARD_PROPS = { "data-file-card": "" } as const; +export const CARD_SELECTOR = "[data-file-card]"; + +/** 卡片内接收键盘焦点的主操作(PotPlayer / 预览 / 下载) */ +export const CARD_FOCUS_PROPS = { "data-card-focus": "" } as const; +export const CARD_FOCUS_SELECTOR = "[data-card-focus]"; + +/** 虚拟列表的行(带 data-index) */ +export const ROW_SELECTOR = "[data-index]"; diff --git a/src/app/(app)/components/library-list.tsx b/src/app/(app)/components/library-list.tsx index c26d9d2..162e4b0 100644 --- a/src/app/(app)/components/library-list.tsx +++ b/src/app/(app)/components/library-list.tsx @@ -1,21 +1,42 @@ +// 资源列表(虚拟滚动) "use client"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from "react"; import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual"; import { TriangleAlertIcon } from "lucide-react"; +import { Reticle } from "@/components/reticle"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { RETICLE } from "@/constants/motion"; import { SOURCE_LABELS } from "@/constants/torbox"; import { useElementWidth } from "@/hooks/use-element-width"; +import { useFollowHighlight } from "@/hooks/use-follow-highlight"; +import { useGridKeyboardNav } from "@/hooks/use-grid-keyboard-nav"; 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 { + 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 { FileGrid, FileGridItem } from "./file-grid"; import { ItemHeader } from "./item-header"; +import { + CARD_FOCUS_SELECTOR, + CARD_PROPS, + CARD_SELECTOR, + ROW_SELECTOR +} from "./library-dom"; import { GRID_GAP, buildLibraryRows, @@ -61,10 +82,13 @@ const getRestoreOffset = ( snapshot: LibraryScrollSnapshot, estimate: (index: number) => number ) => { - const sizes = new Map(snapshot.measurements.map((item) => [String(item.key), item.size])); + 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); + if (rows[index].key === snapshot.anchorKey) + return Math.max(0, offset + snapshot.anchorDelta); offset += sizes.get(rows[index].key) ?? estimate(index); } return 0; @@ -88,7 +112,13 @@ export const LibraryList = (props: LibraryListProps) => { ); }; -const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListProps & { width: number }) => { +const VirtualRows = ({ + scrollRef, + items, + notices, + token, + width +}: LibraryListProps & { width: number }) => { const columns = getColumnCount(width); const cardWidth = (width - GRID_GAP * (columns - 1)) / columns; @@ -129,7 +159,8 @@ const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListPro paddingEnd: PADDING_END, anchorTo: atTop ? "start" : "end", initialMeasurementsCache: restore?.measurements, - initialOffset: () => (restore ? getRestoreOffset(rows, restore, estimateSize) : 0), + initialOffset: () => + restore ? getRestoreOffset(rows, restore, estimateSize) : 0, onChange: (instance) => { setAtTop((instance.scrollOffset ?? 0) <= 1); // 每次滚动停止时保存快照 @@ -158,50 +189,88 @@ const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListPro [virtualizer, saveSnapshot] ); + // 交互层:都挂在同一个容器上做事件委托,彼此独立,删掉一处调用即可拆掉 + const containerRef = useRef(null); + const highlight = useFollowHighlight(containerRef, { + targetSelector: CARD_SELECTOR, + hideDelayMs: RETICLE.hideDelayMs, + outset: RETICLE.outset, + spring: RETICLE.spring, + enterScale: RETICLE.enterScale + }); + useGridKeyboardNav(containerRef, { + cellSelector: CARD_SELECTOR, + focusSelector: CARD_FOCUS_SELECTOR, + rowSelector: ROW_SELECTOR, + rowCount: rows.length, + getCellCount: (index) => { + const row = rows[index]; + return row?.type === "cards" ? row.files.length : 0; + }, + scrollToRow: (index) => virtualizer.scrollToIndex(index, { align: "auto" }) + }); + + // 行结构或列数变化后卡片位置都变了,先收起准星 + const { hide, refresh } = highlight; + useEffect(() => hide(), [rows, columns, hide]); + // 虚拟器每次重新测量行高都会触发渲染,渲染后让准星贴回目标卡片 + useLayoutEffect(() => refresh()); + const virtualRows = virtualizer.getVirtualItems(); const totalSize = virtualizer.getTotalSize(); const first = virtualRows[0]; const last = virtualRows[virtualRows.length - 1]; return ( -
- {/* 不使用定位:可见行处于正常文档流,上下用占位块撑出未渲染部分的高度 */} -