feat: 交互及样式优化

This commit is contained in:
2026-10-05 08:13:35 -07:00
parent 87f3756ff0
commit 79c497f2c6
74 changed files with 2492 additions and 203 deletions
+1
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.3.2", "cn": "^0.3.2",
"lucide-react": "^1.47.0", "lucide-react": "^1.47.0",
"motion": "^14.0.0",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
+54
View File
@@ -29,6 +29,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.47.0 specifier: ^1.47.0
version: 1.47.0(react@19.2.8) 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: next:
specifier: 16.3.5 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) 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==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} 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: fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2338,6 +2352,23 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'} 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: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5002,6 +5033,15 @@ snapshots:
forwarded@0.2.0: {} 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: {} fresh@2.0.0: {}
fs-extra@11.4.0: fs-extra@11.4.0:
@@ -5575,6 +5615,20 @@ snapshots:
minipass@7.1.3: {} 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: {} ms@2.1.3: {}
nanoid@3.3.19: {} nanoid@3.3.19: {}
+1
View File
@@ -1,3 +1,4 @@
// 应用顶栏
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
+34
View File
@@ -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 (
<span
className={cn(
"flex shrink-0 items-center gap-1.5 text-xs",
urgent ? "text-destructive" : "text-muted-foreground"
)}
title={`过期时间 ${formatDateTime(expiresAt)},过期后文件会被 TorBox 清理`}
>
<ClockAlertIcon className="size-3.5" aria-hidden="true" />
{expired ? "已过期" : `${formatDuration(expiresInMs)}后过期`}
</span>
);
};
+141 -26
View File
@@ -1,32 +1,113 @@
// 文件卡片
"use client"; "use client";
import { useState } from "react"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import {
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; 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 { useCopy } from "@/hooks/use-copy";
import type { LibraryFileView } from "@/hooks/use-library"; import type { LibraryFileView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin"; import { useOrigin } from "@/hooks/use-origin";
import { useSpotlight } from "@/hooks/use-spotlight";
import { formatBytes } from "@/utils/format"; 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 }) => ( const ExtLabel = ({ ext }: { ext: string }) => (
<span className="font-mono uppercase">{ext || "file"}</span> <span className="font-mono uppercase">{ext || "file"}</span>
); );
/** 无法预览时的占位封面 */ /** 无法预览时的占位封面 */
const PlaceholderCover = ({ ext, icon: Icon }: { ext: string; icon: typeof FileIcon }) => ( const PlaceholderCover = ({
ext,
icon: Icon
}: {
ext: string;
icon: typeof FileIcon;
}) => (
<div className="flex aspect-video flex-col items-center justify-center gap-1.5 bg-muted/50 text-xs text-muted-foreground"> <div className="flex aspect-video flex-col items-center justify-center gap-1.5 bg-muted/50 text-xs text-muted-foreground">
<Icon className="size-6" strokeWidth={1.5} aria-hidden="true" /> <Icon className="size-6" strokeWidth={1.5} aria-hidden="true" />
<ExtLabel ext={ext} /> <ExtLabel ext={ext} />
</div> </div>
); );
export const FileCard = ({ file, token }: { file: LibraryFileView; token: string }) => { /**
* 视频封面:点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。
* 两层同格叠放、内边距放在内容层,保证点阵对齐。
*/
const VideoCover = ({ ext, href }: { ext: string; href: string }) => {
const { maskImage, opacity, handlers } = useSpotlight(SPOTLIGHT);
return (
<a
href={href}
title="用 PotPlayer 播放"
className="group/cover grid aspect-video bg-muted bg-dot-grid *:col-start-1 *:row-start-1"
{...handlers}
>
<motion.div
aria-hidden="true"
className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.5px]"
style={{ maskImage, opacity }}
/>
<div className="flex flex-col justify-between p-2.5">
<Badge variant="outline">
<ExtLabel ext={ext} />
</Badge>
<span className="flex size-11 items-center justify-center self-center rounded-full bg-primary text-primary-foreground transition-[scale,box-shadow] group-hover/cover:scale-110 group-hover/cover:shadow-[0_0_0_6px] group-hover/cover:shadow-primary/20">
<PlayIcon className="size-5 fill-current" aria-hidden="true" />
</span>
<span className="h-5" aria-hidden="true" />
</div>
</a>
);
};
/** 资源未就绪时,主操作按钮保持原样式但禁用(仍可被键盘聚焦,方向键导航不中断) */
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 origin = useOrigin();
const copy = useCopy(); const copy = useCopy();
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
@@ -34,30 +115,54 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
if (!origin) return null; 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 potPlayerUrl = toPotPlayerUrl(streamUrl);
const available = status === "ready";
let cover: React.ReactNode; let cover: React.ReactNode;
let primaryAction: React.ReactNode; let primaryAction: React.ReactNode;
if (file.kind === "video") { if (!available) {
const action = PENDING_ACTION[file.kind];
cover = ( cover = (
<a <PlaceholderCover
href={potPlayerUrl} ext={file.ext}
title="用 PotPlayer 播放" icon={status === "downloading" ? HourglassIcon : CircleOffIcon}
className="group/cover flex aspect-video flex-col justify-between bg-muted bg-dot-grid p-2.5" />
>
<Badge variant="outline">
<ExtLabel ext={file.ext} />
</Badge>
<span className="flex size-11 items-center justify-center self-center rounded-full bg-primary text-primary-foreground transition-transform group-hover/cover:scale-110">
<PlayIcon className="size-5 fill-current" aria-hidden="true" />
</span>
<span className="h-5" aria-hidden="true" />
</a>
); );
primaryAction = ( primaryAction = (
<Button size="sm" className="flex-1" render={<a href={potPlayerUrl} />} nativeButton={false}> <Button
size="sm"
variant={action.variant}
className="flex-1 aria-disabled:opacity-50"
disabled
focusableWhenDisabled
title={
status === "downloading"
? "资源尚未下载完成"
: "文件已不在 TorBox 服务器上"
}
{...CARD_FOCUS_PROPS}
>
<action.icon data-icon="inline-start" />
{action.label}
</Button>
);
} else if (file.kind === "video") {
cover = <VideoCover ext={file.ext} href={potPlayerUrl} />;
primaryAction = (
<Button
size="sm"
className="flex-1"
render={<a href={potPlayerUrl} />}
nativeButton={false}
{...CARD_FOCUS_PROPS}
>
<PlayIcon data-icon="inline-start" /> <PlayIcon data-icon="inline-start" />
PotPlayer PotPlayer
</Button> </Button>
@@ -83,7 +188,13 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
</button> </button>
); );
primaryAction = ( primaryAction = (
<Button size="sm" variant="secondary" className="flex-1" onClick={() => setPreviewOpen(true)}> <Button
size="sm"
variant="secondary"
className="flex-1"
onClick={() => setPreviewOpen(true)}
{...CARD_FOCUS_PROPS}
>
<EyeIcon data-icon="inline-start" /> <EyeIcon data-icon="inline-start" />
预览 预览
</Button> </Button>
@@ -97,6 +208,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
className="flex-1" className="flex-1"
render={<a href={streamUrl} download={file.displayName} />} render={<a href={streamUrl} download={file.displayName} />}
nativeButton={false} nativeButton={false}
{...CARD_FOCUS_PROPS}
> >
<DownloadIcon data-icon="inline-start" /> <DownloadIcon data-icon="inline-start" />
下载 下载
@@ -105,7 +217,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
} }
return ( return (
<Card size="sm" className="gap-0 py-0 transition-shadow hover:ring-primary/40"> <Card size="sm" className="flex-1 gap-0 py-0">
{cover} {cover}
<CardContent className="flex flex-1 flex-col gap-1 py-3"> <CardContent className="flex flex-1 flex-col gap-1 py-3">
<p className="line-clamp-2 leading-snug break-all" title={file.name}> <p className="line-clamp-2 leading-snug break-all" title={file.name}>
@@ -124,6 +236,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="复制直链" aria-label="复制直链"
disabled={!available}
onClick={() => copy(streamUrl, "已复制直链")} onClick={() => copy(streamUrl, "已复制直链")}
/> />
} }
@@ -134,11 +247,13 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
</Tooltip> </Tooltip>
</CardFooter> </CardFooter>
{file.kind === "image" && ( {available && file.kind === "image" && (
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}> <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="sm:max-w-4xl"> <DialogContent className="sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="truncate pr-8">{file.displayName}</DialogTitle> <DialogTitle className="truncate pr-8">
{file.displayName}
</DialogTitle>
</DialogHeader> </DialogHeader>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
+44 -4
View File
@@ -1,3 +1,9 @@
// 文件卡片网格容器
"use client";
import { motion, type HTMLMotionProps, type Variants } from "motion/react";
import { CARD_ENTER } from "@/constants/motion";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const COLUMN_CLASS: Record<number, string> = { const COLUMN_CLASS: Record<number, string> = {
@@ -7,17 +13,51 @@ const COLUMN_CLASS: Record<number, string> = {
5: "grid-cols-5" 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 列。 * 等宽卡片网格:规范中唯一允许的 grid 布局,其余一律 flex;最多 5 列。
* 传入 columns 时使用固定列数(虚拟列表按容器宽度计算),否则按视口断点 2→3→4→5 列。 * 传入 columns 时使用固定列数(虚拟列表按容器宽度计算),否则按视口断点 2→3→4→5 列。
*/ */
export const FileGrid = ({ columns, children }: { columns?: number; children: React.ReactNode }) => ( export const FileGrid = ({ columns, animated, children }: FileGridProps) => (
<div <motion.div
variants={gridVariants}
initial={animated ? "hidden" : false}
animate="visible"
className={cn( className={cn(
"grid gap-3", "grid gap-3",
columns ? COLUMN_CLASS[columns] : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5" columns
? COLUMN_CLASS[columns]
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
)} )}
> >
{children} {children}
</div> </motion.div>
);
/** 网格单元:继承 FileGrid 的入场编排,透传 data-* 等属性 */
export const FileGridItem = ({
className,
...props
}: HTMLMotionProps<"div">) => (
<motion.div
variants={itemVariants}
className={cn("flex min-w-0 flex-col", className)}
{...props}
/>
); );
@@ -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 (
<span
className="flex shrink-0 items-center gap-1.5 text-xs text-destructive"
title="下载已完成,但文件已不在 TorBox 服务器上"
>
<CircleOffIcon className="size-3.5" aria-hidden="true" />
文件不可用
</span>
);
}
const percent = Math.floor(item.progress * 100);
const label = DOWNLOAD_STATE_LABELS[item.downloadState ?? ""] ?? "处理中";
return (
<span
className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground"
title={item.downloadState ?? undefined}
>
<Progress
value={percent}
aria-label="下载进度"
className="hidden w-20 sm:flex"
/>
<span className="font-mono tabular-nums min-w-8">{percent}%</span>
<span>{label}</span>
</span>
);
};
+7
View File
@@ -1,3 +1,4 @@
// 资源条目标题行
"use client"; "use client";
import { ChevronRightIcon } from "lucide-react"; import { ChevronRightIcon } from "lucide-react";
@@ -8,6 +9,9 @@ import { SOURCE_LABELS } from "@/constants/torbox";
import type { LibraryItemView } from "@/hooks/use-library"; import type { LibraryItemView } from "@/hooks/use-library";
import { formatBytes, formatCount } from "@/utils/format"; 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"; import { SourceDot } from "./source-dot";
type ItemHeaderProps = { type ItemHeaderProps = {
@@ -34,9 +38,12 @@ export const ItemHeader = ({ item, expanded, onToggle }: ItemHeaderProps) => (
<SourceDot source={item.source} /> <SourceDot source={item.source} />
{SOURCE_LABELS[item.source]} {SOURCE_LABELS[item.source]}
</Badge> </Badge>
{item.isNew && <NewBadge createdAt={item.createdAt} />}
<h2 className="min-w-0 flex-1 truncate font-medium" title={item.name}> <h2 className="min-w-0 flex-1 truncate font-medium" title={item.name}>
{item.name} {item.name}
</h2> </h2>
<ItemDownloadStatus item={item} />
<ExpiryBadge item={item} />
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums"> <span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatCount(item.files.length)} 文件 {formatCount(item.files.length)} 文件
<span className="hidden sm:inline"> · {formatBytes(item.totalSize)}</span> <span className="hidden sm:inline"> · {formatBytes(item.totalSize)}</span>
+15
View File
@@ -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]";
+114 -45
View File
@@ -1,21 +1,42 @@
// 资源列表(虚拟滚动)
"use client"; "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 { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
import { TriangleAlertIcon } from "lucide-react"; import { TriangleAlertIcon } from "lucide-react";
import { Reticle } from "@/components/reticle";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { RETICLE } from "@/constants/motion";
import { SOURCE_LABELS } from "@/constants/torbox"; import { SOURCE_LABELS } from "@/constants/torbox";
import { useElementWidth } from "@/hooks/use-element-width"; 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 type { LibraryItemView } from "@/hooks/use-library";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useLibraryFilterStore } from "@/stores/library-filter-store"; 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 type { SourceType } from "@/types/torbox";
import { FileCard } from "./file-card"; import { FileCard } from "./file-card";
import { FileGrid } from "./file-grid"; import { FileGrid, FileGridItem } from "./file-grid";
import { ItemHeader } from "./item-header"; import { ItemHeader } from "./item-header";
import {
CARD_FOCUS_SELECTOR,
CARD_PROPS,
CARD_SELECTOR,
ROW_SELECTOR
} from "./library-dom";
import { import {
GRID_GAP, GRID_GAP,
buildLibraryRows, buildLibraryRows,
@@ -61,10 +82,13 @@ const getRestoreOffset = (
snapshot: LibraryScrollSnapshot, snapshot: LibraryScrollSnapshot,
estimate: (index: number) => number 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; let offset = PADDING_START;
for (let index = 0; index < rows.length; index += 1) { 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); offset += sizes.get(rows[index].key) ?? estimate(index);
} }
return 0; 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 columns = getColumnCount(width);
const cardWidth = (width - GRID_GAP * (columns - 1)) / columns; const cardWidth = (width - GRID_GAP * (columns - 1)) / columns;
@@ -129,7 +159,8 @@ const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListPro
paddingEnd: PADDING_END, paddingEnd: PADDING_END,
anchorTo: atTop ? "start" : "end", anchorTo: atTop ? "start" : "end",
initialMeasurementsCache: restore?.measurements, initialMeasurementsCache: restore?.measurements,
initialOffset: () => (restore ? getRestoreOffset(rows, restore, estimateSize) : 0), initialOffset: () =>
restore ? getRestoreOffset(rows, restore, estimateSize) : 0,
onChange: (instance) => { onChange: (instance) => {
setAtTop((instance.scrollOffset ?? 0) <= 1); setAtTop((instance.scrollOffset ?? 0) <= 1);
// 每次滚动停止时保存快照 // 每次滚动停止时保存快照
@@ -158,50 +189,88 @@ const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListPro
[virtualizer, saveSnapshot] [virtualizer, saveSnapshot]
); );
// 交互层:都挂在同一个容器上做事件委托,彼此独立,删掉一处调用即可拆掉
const containerRef = useRef<HTMLDivElement>(null);
const highlight = useFollowHighlight<HTMLDivElement>(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 virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize(); const totalSize = virtualizer.getTotalSize();
const first = virtualRows[0]; const first = virtualRows[0];
const last = virtualRows[virtualRows.length - 1]; const last = virtualRows[virtualRows.length - 1];
return ( return (
<div className="flex flex-col"> // 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动,不使用定位
{/* 不使用定位:可见行处于正常文档流,上下用占位块撑出未渲染部分的高度 */} <div ref={containerRef} className="grid *:col-start-1 *:row-start-1">
<div aria-hidden="true" style={{ height: first ? first.start : 0 }} /> <div className="flex flex-col">
{virtualRows.map((virtualRow) => { {/* 不使用定位:可见行处于正常文档流,上下用占位块撑出未渲染部分的高度 */}
const row = rows[virtualRow.index]; <div aria-hidden="true" style={{ height: first ? first.start : 0 }} />
return ( {virtualRows.map((virtualRow) => {
<div const row = rows[virtualRow.index];
key={virtualRow.key} return (
data-index={virtualRow.index} <div
ref={virtualizer.measureElement} key={virtualRow.key}
className={rowClassName(row)} data-index={virtualRow.index}
> ref={virtualizer.measureElement}
{row.type === "notice" && ( className={rowClassName(row)}
<Alert variant="destructive"> >
<TriangleAlertIcon /> {row.type === "notice" && (
<AlertTitle>{SOURCE_LABELS[row.source]} 拉取失败</AlertTitle> <Alert variant="destructive">
<AlertDescription>{row.detail}</AlertDescription> <TriangleAlertIcon />
</Alert> <AlertTitle>{SOURCE_LABELS[row.source]} 拉取失败</AlertTitle>
)} <AlertDescription>{row.detail}</AlertDescription>
{row.type === "header" && ( </Alert>
<ItemHeader )}
item={row.item} {row.type === "header" && (
expanded={row.expanded} <ItemHeader
onToggle={() => toggleCollapsed(row.itemKey)} item={row.item}
/> expanded={row.expanded}
)} onToggle={() => toggleCollapsed(row.itemKey)}
{row.type === "cards" && ( />
<FileGrid columns={columns}> )}
{row.files.map((file) => ( {row.type === "cards" && (
<FileCard key={file.id} file={file} token={token} /> <FileGrid columns={columns} animated>
))} {row.files.map((file) => (
</FileGrid> <FileGridItem key={file.id} {...CARD_PROPS}>
)} <FileCard file={file} token={token} status={row.status} />
{row.type === "empty" && <p className="pl-9 text-sm text-muted-foreground">暂无文件</p>} </FileGridItem>
</div> ))}
); </FileGrid>
})} )}
<div aria-hidden="true" style={{ height: last ? totalSize - last.end : totalSize }} /> {row.type === "empty" && (
<p className="pl-9 text-sm text-muted-foreground">暂无文件</p>
)}
</div>
);
})}
<div
aria-hidden="true"
style={{ height: last ? totalSize - last.end : totalSize }}
/>
</div>
<Reticle ref={highlight.ref} style={highlight.style} />
</div> </div>
); );
}; };
+32 -7
View File
@@ -1,5 +1,6 @@
// 资源列表行数据构建与网格布局计算
import type { LibraryFileView, LibraryItemView } from "@/hooks/use-library"; import type { LibraryFileView, LibraryItemView } from "@/hooks/use-library";
import type { SourceType } from "@/types/torbox"; import type { DownloadStatus, SourceType } from "@/types/torbox";
/** 卡片网格参数:与 FileGrid 的 gap-3 保持一致 */ /** 卡片网格参数:与 FileGrid 的 gap-3 保持一致 */
export const GRID_GAP = 12; export const GRID_GAP = 12;
@@ -10,17 +11,33 @@ const MAX_COLUMNS = 5;
export const getColumnCount = (width: number) => export const getColumnCount = (width: number) =>
Math.min( Math.min(
MAX_COLUMNS, MAX_COLUMNS,
Math.max(MIN_COLUMNS, Math.floor((width + GRID_GAP) / (MIN_CARD_WIDTH + GRID_GAP))) Math.max(
MIN_COLUMNS,
Math.floor((width + GRID_GAP) / (MIN_CARD_WIDTH + GRID_GAP))
)
); );
/** 虚拟列表的一行:来源错误提示、资源标题,或一整行文件卡片 */ /** 虚拟列表的一行:来源错误提示、资源标题,或一整行文件卡片 */
export type LibraryRow = export type LibraryRow =
| { type: "notice"; key: string; source: SourceType; detail: string } | { type: "notice"; key: string; source: SourceType; detail: string }
| { type: "header"; key: string; itemKey: string; item: LibraryItemView; expanded: boolean; first: boolean } | {
| { type: "cards"; key: string; files: LibraryFileView[] } type: "header";
key: string;
itemKey: string;
item: LibraryItemView;
expanded: boolean;
first: boolean;
}
| {
type: "cards";
key: string;
files: LibraryFileView[];
status: DownloadStatus;
}
| { type: "empty"; key: string }; | { type: "empty"; key: string };
export const getItemKey = (item: LibraryItemView) => `${item.source}-${item.id}`; export const getItemKey = (item: LibraryItemView) =>
`${item.source}-${item.id}`;
/** /**
* 把分组数据拍平成行。行 key 只依赖资源与分块序号、不依赖列数, * 把分组数据拍平成行。行 key 只依赖资源与分块序号、不依赖列数,
@@ -42,7 +59,14 @@ export const buildLibraryRows = (
for (const item of items) { for (const item of items) {
const itemKey = getItemKey(item); const itemKey = getItemKey(item);
const expanded = !collapsed.has(itemKey); const expanded = !collapsed.has(itemKey);
rows.push({ type: "header", key: `header:${itemKey}`, itemKey, item, expanded, first: rows.length === 0 }); rows.push({
type: "header",
key: `header:${itemKey}`,
itemKey,
item,
expanded,
first: rows.length === 0
});
if (!expanded) continue; if (!expanded) continue;
if (item.files.length === 0) { if (item.files.length === 0) {
@@ -53,7 +77,8 @@ export const buildLibraryRows = (
rows.push({ rows.push({
type: "cards", type: "cards",
key: `cards:${itemKey}:${start / columns}`, key: `cards:${itemKey}:${start / columns}`,
files: item.files.slice(start, start + columns) files: item.files.slice(start, start + columns),
status: item.status
}); });
} }
} }
@@ -1,9 +1,14 @@
// 资源列表加载骨架屏
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { FileGrid } from "./file-grid"; import { FileGrid } from "./file-grid";
export const LibrarySkeleton = () => ( export const LibrarySkeleton = () => (
<div className="flex flex-col gap-8" aria-busy="true" aria-label="正在加载资源"> <div
className="flex flex-col gap-8"
aria-busy="true"
aria-label="正在加载资源"
>
{Array.from({ length: 2 }, (_, section) => ( {Array.from({ length: 2 }, (_, section) => (
<div key={section} className="flex flex-col gap-3"> <div key={section} className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+54 -11
View File
@@ -1,7 +1,10 @@
// 资源列表工具栏(搜索、筛选、刷新)
"use client"; "use client";
import { useRef } from "react";
import { RefreshCwIcon, SearchIcon, XIcon } from "lucide-react"; import { RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
import { AnimatedNumber } from "@/components/animated-number";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
InputGroup, InputGroup,
@@ -9,10 +12,16 @@ import {
InputGroupButton, InputGroupButton,
InputGroupInput InputGroupInput
} from "@/components/ui/input-group"; } from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipTrigger
} from "@/components/ui/tooltip";
import { SOURCES, SOURCE_LABELS } from "@/constants/torbox"; import { SOURCES, SOURCE_LABELS } from "@/constants/torbox";
import { useHotkey } from "@/hooks/use-hotkey";
import { import {
useLibraryFilterStore, useLibraryFilterStore,
type KindFilter, type KindFilter,
@@ -22,6 +31,9 @@ import { formatBytes, formatCount } from "@/utils/format";
import { SourceDot } from "./source-dot"; import { SourceDot } from "./source-dot";
// 数字滚动过程中的格式化:计数取整,大小按 1024 进制
const formatInteger = (value: number) => formatCount(Math.round(value));
const KIND_OPTIONS: { value: KindFilter; label: string }[] = [ const KIND_OPTIONS: { value: KindFilter; label: string }[] = [
{ value: "all", label: "全部类型" }, { value: "all", label: "全部类型" },
{ value: "video", label: "视频" }, { value: "video", label: "视频" },
@@ -49,6 +61,13 @@ export const LibraryToolbar = ({
const setSource = useLibraryFilterStore((state) => state.setSource); const setSource = useLibraryFilterStore((state) => state.setSource);
const setKind = useLibraryFilterStore((state) => state.setKind); const setKind = useLibraryFilterStore((state) => state.setKind);
// "/" 聚焦搜索;搜索框内 Esc 先清空,已为空则失焦
const searchRef = useRef<HTMLInputElement>(null);
useHotkey("/", (event) => {
event.preventDefault();
searchRef.current?.focus();
});
return ( return (
<div className="shrink-0 border-b"> <div className="shrink-0 border-b">
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 md:px-6"> <div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 md:px-6">
@@ -57,17 +76,30 @@ export const LibraryToolbar = ({
<SearchIcon /> <SearchIcon />
</InputGroupAddon> </InputGroupAddon>
<InputGroupInput <InputGroupInput
ref={searchRef}
placeholder="搜索资源或文件名" placeholder="搜索资源或文件名"
aria-keyshortcuts="/"
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
if (keyword) setKeyword("");
else event.currentTarget.blur();
}}
/> />
{keyword && ( <InputGroupAddon align="inline-end">
<InputGroupAddon align="inline-end"> {keyword ? (
<InputGroupButton size="icon-xs" aria-label="清除搜索" onClick={() => setKeyword("")}> <InputGroupButton
size="icon-xs"
aria-label="清除搜索"
onClick={() => setKeyword("")}
>
<XIcon /> <XIcon />
</InputGroupButton> </InputGroupButton>
</InputGroupAddon> ) : (
)} <Kbd>/</Kbd>
)}
</InputGroupAddon>
</InputGroup> </InputGroup>
{/* 单选:再次点击已选项时保持当前值,不允许清空 */} {/* 单选:再次点击已选项时保持当前值,不允许清空 */}
@@ -77,17 +109,27 @@ export const LibraryToolbar = ({
size="sm" size="sm"
spacing={0} spacing={0}
value={[source]} value={[source]}
onValueChange={(value) => value[0] && setSource(value[0] as SourceFilter)} onValueChange={(value) =>
value[0] && setSource(value[0] as SourceFilter)
}
> >
<ToggleGroupItem value="all"> <ToggleGroupItem value="all">
全部 全部
<span className="font-mono text-muted-foreground tabular-nums">{sourceCounts.all}</span> <AnimatedNumber
value={sourceCounts.all}
format={formatInteger}
className="font-mono text-muted-foreground tabular-nums"
/>
</ToggleGroupItem> </ToggleGroupItem>
{SOURCES.map(({ type }) => ( {SOURCES.map(({ type }) => (
<ToggleGroupItem key={type} value={type}> <ToggleGroupItem key={type} value={type}>
<SourceDot source={type} /> <SourceDot source={type} />
{SOURCE_LABELS[type]} {SOURCE_LABELS[type]}
<span className="font-mono text-muted-foreground tabular-nums">{sourceCounts[type]}</span> <AnimatedNumber
value={sourceCounts[type]}
format={formatInteger}
className="font-mono text-muted-foreground tabular-nums"
/>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
</ToggleGroup> </ToggleGroup>
@@ -109,8 +151,9 @@ export const LibraryToolbar = ({
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground tabular-nums"> <span className="font-mono text-xs text-muted-foreground tabular-nums">
{formatCount(summary.items)} 资源 · {formatCount(summary.files)} 文件 ·{" "} <AnimatedNumber value={summary.items} format={formatInteger} /> 资源
{formatBytes(summary.size)} · <AnimatedNumber value={summary.files} format={formatInteger} />{" "}
文件 · <AnimatedNumber value={summary.size} format={formatBytes} />
</span> </span>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
+30 -9
View File
@@ -1,9 +1,15 @@
// 资源库视图(数据获取与状态分发)
"use client"; "use client";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { FolderOpenIcon, SearchXIcon, TriangleAlertIcon } from "lucide-react"; import { FolderOpenIcon, SearchXIcon, TriangleAlertIcon } from "lucide-react";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"; import {
Alert,
AlertAction,
AlertDescription,
AlertTitle
} from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Empty, Empty,
@@ -14,7 +20,7 @@ import {
EmptyTitle EmptyTitle
} from "@/components/ui/empty"; } from "@/components/ui/empty";
import { SOURCE_LABELS } from "@/constants/torbox"; import { SOURCE_LABELS } from "@/constants/torbox";
import { useFilteredLibrary } from "@/hooks/use-library"; import { useFilteredLibrary, useRefreshLibrary } from "@/hooks/use-library";
import { useLibraryFilterStore } from "@/stores/library-filter-store"; import { useLibraryFilterStore } from "@/stores/library-filter-store";
import type { SourceType } from "@/types/torbox"; import type { SourceType } from "@/types/torbox";
@@ -23,7 +29,9 @@ import { LibrarySkeleton } from "./library-skeleton";
import { LibraryToolbar } from "./library-toolbar"; import { LibraryToolbar } from "./library-toolbar";
export const LibraryView = ({ token }: { token: string }) => { export const LibraryView = ({ token }: { token: string }) => {
const { query, items, sourceCounts, summary, isFiltered, isEmptyLibrary } = useFilteredLibrary(); const { query, items, sourceCounts, summary, isFiltered, isEmptyLibrary } =
useFilteredLibrary();
const refresh = useRefreshLibrary();
const resetFilter = useLibraryFilterStore((state) => state.reset); const resetFilter = useLibraryFilterStore((state) => state.reset);
// 滚动容器由页面持有,虚拟列表只负责计算渲染哪些行 // 滚动容器由页面持有,虚拟列表只负责计算渲染哪些行
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -41,7 +49,9 @@ export const LibraryView = ({ token }: { token: string }) => {
<Alert variant="destructive"> <Alert variant="destructive">
<TriangleAlertIcon /> <TriangleAlertIcon />
<AlertTitle>资源加载失败</AlertTitle> <AlertTitle>资源加载失败</AlertTitle>
<AlertDescription>请检查服务端 TORBOX_API_KEY 配置或网络连接</AlertDescription> <AlertDescription>
请检查服务端 TORBOX_API_KEY 配置或网络连接
</AlertDescription>
<AlertAction> <AlertAction>
<Button size="sm" variant="outline" onClick={() => query.refetch()}> <Button size="sm" variant="outline" onClick={() => query.refetch()}>
重试 重试
@@ -70,7 +80,9 @@ export const LibraryView = ({ token }: { token: string }) => {
<FolderOpenIcon /> <FolderOpenIcon />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>账号里还没有资源</EmptyTitle> <EmptyTitle>账号里还没有资源</EmptyTitle>
<EmptyDescription>在 TorBox 添加下载任务后,点击刷新即可在这里播放</EmptyDescription> <EmptyDescription>
在 TorBox 添加下载任务后,点击刷新即可在这里播放
</EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
) : ( ) : (
@@ -80,7 +92,9 @@ export const LibraryView = ({ token }: { token: string }) => {
<SearchXIcon /> <SearchXIcon />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>没有匹配的文件</EmptyTitle> <EmptyTitle>没有匹配的文件</EmptyTitle>
<EmptyDescription>换个关键词,或调整来源与类型筛选</EmptyDescription> <EmptyDescription>
换个关键词,或调整来源与类型筛选
</EmptyDescription>
</EmptyHeader> </EmptyHeader>
{isFiltered && ( {isFiltered && (
<EmptyContent> <EmptyContent>
@@ -101,13 +115,20 @@ export const LibraryView = ({ token }: { token: string }) => {
sourceCounts={sourceCounts} sourceCounts={sourceCounts}
summary={summary} summary={summary}
isFetching={query.isFetching} isFetching={query.isFetching}
onRefresh={() => query.refetch()} onRefresh={refresh}
/> />
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto"> <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
{content ? ( {content ? (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 md:p-6">{content}</div> <div className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 md:p-6">
{content}
</div>
) : ( ) : (
<LibraryList scrollRef={scrollRef} items={items} notices={notices} token={token} /> <LibraryList
scrollRef={scrollRef}
items={items}
notices={notices}
token={token}
/>
)} )}
</div> </div>
</div> </div>
+6 -1
View File
@@ -1,10 +1,15 @@
// 退出登录按钮
"use client"; "use client";
import { LogOutIcon } from "lucide-react"; import { LogOutIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipTrigger
} from "@/components/ui/tooltip";
import { useLogout } from "@/hooks/use-auth"; import { useLogout } from "@/hooks/use-auth";
export const LogoutButton = () => { export const LogoutButton = () => {
+23
View File
@@ -0,0 +1,23 @@
// 新增资源徽标
"use client";
import { motion } from "motion/react";
import { BREATHE } from "@/constants/motion";
import { formatDateTime } from "@/utils/date";
/** 新加入资源的标记:翠绿呼吸点 + 等宽 NEW,悬停显示加入时间 */
export const NewBadge = ({ createdAt }: { createdAt: string | null }) => (
<span
className="flex shrink-0 items-center gap-1.5 font-mono text-[0.65rem] tracking-[0.2em] text-primary"
title={createdAt ? `加入于 ${formatDateTime(createdAt)}` : undefined}
>
<motion.span
aria-hidden="true"
className="size-1.5 rounded-full bg-primary"
animate={{ opacity: [1, 0.35, 1], scale: [1, 0.75, 1] }}
transition={BREATHE}
/>
NEW
</span>
);
+17 -6
View File
@@ -1,6 +1,12 @@
// 播放列表菜单
"use client"; "use client";
import { ChevronDownIcon, CopyIcon, ListVideoIcon, PlayIcon } from "lucide-react"; import {
ChevronDownIcon,
CopyIcon,
ListVideoIcon,
PlayIcon
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -22,7 +28,9 @@ export const PlaylistMenu = ({ token }: { token: string }) => {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" disabled={!playlistUrl} />}> <DropdownMenuTrigger
render={<Button variant="ghost" disabled={!playlistUrl} />}
>
<ListVideoIcon data-icon="inline-start" /> <ListVideoIcon data-icon="inline-start" />
播放列表 播放列表
<ChevronDownIcon data-icon="inline-end" /> <ChevronDownIcon data-icon="inline-end" />
@@ -30,11 +38,14 @@ export const PlaylistMenu = ({ token }: { token: string }) => {
<DropdownMenuContent align="end" className="w-52"> <DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel>全部资源 · M3U</DropdownMenuLabel> <DropdownMenuLabel>全部资源 · M3U</DropdownMenuLabel>
<DropdownMenuItem onClick={() => window.location.assign(toPotPlayerUrl(playlistUrl))}> <DropdownMenuItem
<PlayIcon /> onClick={() => window.location.assign(toPotPlayerUrl(playlistUrl))}
用 PotPlayer 打开 >
<PlayIcon />用 PotPlayer 打开
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => copy(playlistUrl, "已复制 M3U 地址")}> <DropdownMenuItem
onClick={() => copy(playlistUrl, "已复制 M3U 地址")}
>
<CopyIcon /> <CopyIcon />
复制 M3U 地址 复制 M3U 地址
</DropdownMenuItem> </DropdownMenuItem>
+5 -1
View File
@@ -1,3 +1,4 @@
// 资源来源标识圆点
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { SourceType } from "@/types/torbox"; import type { SourceType } from "@/types/torbox";
@@ -9,5 +10,8 @@ const SOURCE_DOT_CLASS: Record<SourceType, string> = {
}; };
export const SourceDot = ({ source }: { source: SourceType }) => ( export const SourceDot = ({ source }: { source: SourceType }) => (
<span aria-hidden="true" className={cn("size-1.5 shrink-0 rounded-full", SOURCE_DOT_CLASS[source])} /> <span
aria-hidden="true"
className={cn("size-1.5 shrink-0 rounded-full", SOURCE_DOT_CLASS[source])}
/>
); );
+1
View File
@@ -1,3 +1,4 @@
// 登录后页面布局
import { requireSessionToken } from "@/server/session"; import { requireSessionToken } from "@/server/session";
import { AppHeader } from "./components/app-header"; import { AppHeader } from "./components/app-header";
+1
View File
@@ -1,3 +1,4 @@
// 资源库首页
import type { Metadata } from "next"; import type { Metadata } from "next";
import { requireSessionToken } from "@/server/session"; import { requireSessionToken } from "@/server/session";
+5 -1
View File
@@ -1,3 +1,4 @@
// 登录接口
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { SESSION_COOKIE, SESSION_MAX_AGE } from "@/constants/auth"; import { SESSION_COOKIE, SESSION_MAX_AGE } from "@/constants/auth";
@@ -23,5 +24,8 @@ export async function POST(req: NextRequest) {
path: "/", path: "/",
maxAge: SESSION_MAX_AGE maxAge: SESSION_MAX_AGE
}); });
return Response.json({ success: true }, { headers: { "Cache-Control": "no-store" } }); return Response.json(
{ success: true },
{ headers: { "Cache-Control": "no-store" } }
);
} }
+5 -1
View File
@@ -1,3 +1,4 @@
// 退出登录接口
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { SESSION_COOKIE } from "@/constants/auth"; import { SESSION_COOKIE } from "@/constants/auth";
@@ -5,5 +6,8 @@ export const dynamic = "force-dynamic";
export async function POST() { export async function POST() {
(await cookies()).delete(SESSION_COOKIE); (await cookies()).delete(SESSION_COOKIE);
return Response.json({ success: true }, { headers: { "Cache-Control": "no-store" } }); return Response.json(
{ success: true },
{ headers: { "Cache-Control": "no-store" } }
);
} }
+8 -2
View File
@@ -1,3 +1,4 @@
// 资源列表接口
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth"; import { guard, jsonError } from "@/server/auth";
import { getLibrary } from "@/server/torbox"; import { getLibrary } from "@/server/torbox";
@@ -8,11 +9,16 @@ export async function GET(req: NextRequest) {
const denied = guard(req); const denied = guard(req);
if (denied) return denied; if (denied) return denied;
try { try {
// ?fresh=1:用户手动刷新,绕过 TorBox 列表缓存
const bypassCache = req.nextUrl.searchParams.get("fresh") === "1";
return Response.json( return Response.json(
{ success: true, data: await getLibrary() }, { success: true, data: await getLibrary({ bypassCache }) },
{ headers: { "Cache-Control": "no-store" } } { headers: { "Cache-Control": "no-store" } }
); );
} catch (error) { } catch (error) {
return jsonError(502, error instanceof Error ? error.message : "upstream error"); return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
} }
} }
+19 -4
View File
@@ -1,3 +1,4 @@
// M3U 播放列表接口
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth"; import { guard, jsonError } from "@/server/auth";
import { getLibrary } from "@/server/torbox"; import { getLibrary } from "@/server/torbox";
@@ -13,16 +14,30 @@ export async function GET(req: NextRequest) {
const { origin, searchParams } = new URL(req.url); const { origin, searchParams } = new URL(req.url);
const token = searchParams.get("token") ?? ""; const token = searchParams.get("token") ?? "";
const lines = ["#EXTM3U"]; const lines = ["#EXTM3U"];
// 只收录可播放的资源,下载中或已失效的文件放进播放列表也无法播放
for (const item of items) { for (const item of items) {
if (item.status !== "ready") continue;
for (const file of item.files) { for (const file of item.files) {
const title = `${item.name} / ${file.short_name ?? file.name}`.replace(/[\r\n,]/g, " "); const title = `${item.name} / ${file.short_name ?? file.name}`.replace(
lines.push(`#EXTINF:-1,${title}`, buildStreamUrl(origin, file.playUrl, file.name, token)); /[\r\n,]/g,
" "
);
lines.push(
`#EXTINF:-1,${title}`,
buildStreamUrl(origin, file.playUrl, file.name, token)
);
} }
} }
return new Response(lines.join("\r\n") + "\r\n", { return new Response(lines.join("\r\n") + "\r\n", {
headers: { "Content-Type": "audio/x-mpegurl; charset=utf-8", "Cache-Control": "no-store" } headers: {
"Content-Type": "audio/x-mpegurl; charset=utf-8",
"Cache-Control": "no-store"
}
}); });
} catch (error) { } catch (error) {
return jsonError(502, error instanceof Error ? error.message : "upstream error"); return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
} }
} }
@@ -1,3 +1,4 @@
// 播放流代理接口
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth"; import { guard, jsonError } from "@/server/auth";
import { isSourceType, requestDownloadUrl } from "@/server/torbox"; import { isSourceType, requestDownloadUrl } from "@/server/torbox";
@@ -16,7 +17,11 @@ const PASS_HEADERS = [
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> }; type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
const proxy = async (req: NextRequest, { params }: Ctx, method: "GET" | "HEAD") => { const proxy = async (
req: NextRequest,
{ params }: Ctx,
method: "GET" | "HEAD"
) => {
const denied = guard(req); const denied = guard(req);
if (denied) return denied; if (denied) return denied;
@@ -45,7 +50,10 @@ const proxy = async (req: NextRequest, { params }: Ctx, method: "GET" | "HEAD")
headers headers
}); });
} catch (error) { } catch (error) {
return jsonError(502, error instanceof Error ? error.message : "upstream error"); return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
} }
}; };
+36 -4
View File
@@ -1,3 +1,5 @@
/* 全局样式与主题变量 */
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@@ -7,7 +9,8 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans), "PingFang SC", "Microsoft YaHei", sans-serif; --font-sans:
var(--font-geist-sans), "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace; --font-mono: var(--font-geist-mono), ui-monospace, monospace;
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--font-display: var(--font-doto), var(--font-geist-mono), monospace; --font-display: var(--font-doto), var(--font-geist-mono), monospace;
@@ -87,12 +90,41 @@
--sidebar-ring: oklch(0.713 0.15 164.6); --sidebar-ring: oklch(0.713 0.15 164.6);
} }
/* 点阵背景:呼应 TorBox 点阵字标,仅用于登录页背景与视频卡封面 */ /* 点阵背景:呼应 TorBox 点阵字标,仅用于登录页背景与视频卡封面;
--dot-color / --dot-size 可覆盖,用于叠一层「点亮」的点阵 */
@utility bg-dot-grid { @utility bg-dot-grid {
background-image: radial-gradient(var(--border) 1px, transparent 1px); background-image: radial-gradient(
var(--dot-color, var(--border)) var(--dot-size, 1px),
transparent var(--dot-size, 1px)
);
background-size: 14px 14px; background-size: 14px 14px;
} }
/* 准星四角:8 条线性渐变画出 L 形角标,不需要额外元素 */
@utility bg-corner-brackets {
--bracket-color: var(--primary);
--bracket-length: 12px;
--bracket-width: 2px;
background:
linear-gradient(var(--bracket-color), var(--bracket-color)) top left /
var(--bracket-length) var(--bracket-width),
linear-gradient(var(--bracket-color), var(--bracket-color)) top left /
var(--bracket-width) var(--bracket-length),
linear-gradient(var(--bracket-color), var(--bracket-color)) top right /
var(--bracket-length) var(--bracket-width),
linear-gradient(var(--bracket-color), var(--bracket-color)) top right /
var(--bracket-width) var(--bracket-length),
linear-gradient(var(--bracket-color), var(--bracket-color)) bottom left /
var(--bracket-length) var(--bracket-width),
linear-gradient(var(--bracket-color), var(--bracket-color)) bottom left /
var(--bracket-width) var(--bracket-length),
linear-gradient(var(--bracket-color), var(--bracket-color)) bottom right /
var(--bracket-length) var(--bracket-width),
linear-gradient(var(--bracket-color), var(--bracket-color)) bottom right /
var(--bracket-width) var(--bracket-length);
background-repeat: no-repeat;
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
@@ -103,4 +135,4 @@
html { html {
@apply font-sans; @apply font-sans;
} }
} }
+20 -4
View File
@@ -1,15 +1,24 @@
// 根布局
import "./globals.css"; import "./globals.css";
import { Doto, Geist, Geist_Mono } from "next/font/google"; import { Doto, Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/toast"; import { Toaster } from "@/components/ui/toast";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import MotionProvider from "@/providers/motion-provider";
import QueryProvider from "@/providers/query-provider"; import QueryProvider from "@/providers/query-provider";
import type { Metadata } from "next"; import type { Metadata } from "next";
const geistSans = Geist({ subsets: ["latin"], variable: "--font-geist-sans" }); const geistSans = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" }); const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono"
});
// 点阵字体仅用于 TORBOX 字标 // 点阵字体仅用于 TORBOX 字标
const doto = Doto({ subsets: ["latin"], weight: "800", variable: "--font-doto" }); const doto = Doto({
subsets: ["latin"],
weight: "800",
variable: "--font-doto"
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -26,11 +35,18 @@ export default function RootLayout({
return ( return (
<html <html
lang="zh-CN" lang="zh-CN"
className={cn("dark antialiased", geistSans.variable, geistMono.variable, doto.variable)} className={cn(
"dark antialiased",
geistSans.variable,
geistMono.variable,
doto.variable
)}
> >
<body> <body>
<QueryProvider> <QueryProvider>
<TooltipProvider>{children}</TooltipProvider> <MotionProvider>
<TooltipProvider>{children}</TooltipProvider>
</MotionProvider>
</QueryProvider> </QueryProvider>
<Toaster /> <Toaster />
</body> </body>
+33 -6
View File
@@ -1,7 +1,9 @@
// 登录表单
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { useAnimate } from "motion/react";
import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react"; import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
@@ -26,22 +28,39 @@ import {
InputGroupInput InputGroupInput
} from "@/components/ui/input-group"; } from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { SCRAMBLE, SHAKE } from "@/constants/motion";
import { useLogin } from "@/hooks/use-auth"; import { useLogin } from "@/hooks/use-auth";
const FORM_ID = "login-form"; const FORM_ID = "login-form";
export const LoginForm = () => { const isUnauthorized = (error: unknown) =>
isAxiosError(error) && error.response?.status === 401;
/** 表单只通过回调对外通知,不关心外部如何呈现(如背景点阵场) */
type LoginFormProps = {
/** 输入内容变化 */
onTyping?: (input: HTMLElement) => void;
/** 密钥错误 */
onReject?: (card: HTMLElement) => void;
/** 密钥正确、跳转前;返回 Promise 时等待其完成再跳转 */
onAccept?: (card: HTMLElement) => Promise<void> | void;
};
export const LoginForm = ({ onTyping, onReject, onAccept }: LoginFormProps) => {
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { mutate, isPending, isSuccess, error, reset } = useLogin(); const [scope, animate] = useAnimate<HTMLDivElement>();
const invalid = isAxiosError(error) && error.response?.status === 401; const { mutate, isPending, isSuccess, error, reset } = useLogin({
beforeNavigate: () => onAccept?.(scope.current)
});
const invalid = isUnauthorized(error);
const busy = isPending || isSuccess; const busy = isPending || isSuccess;
return ( return (
<Card className="w-full max-w-sm"> <Card ref={scope} className="w-full max-w-sm">
<CardHeader> <CardHeader>
<div className="mb-4"> <div className="mb-4">
<BrandMark /> <BrandMark scrambleOnMount={SCRAMBLE.mountDelay} />
</div> </div>
<CardTitle>输入访问密钥</CardTitle> <CardTitle>输入访问密钥</CardTitle>
</CardHeader> </CardHeader>
@@ -50,7 +69,14 @@ export const LoginForm = () => {
id={FORM_ID} id={FORM_ID}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (token.trim()) mutate(token.trim()); if (!token.trim()) return;
mutate(token.trim(), {
onError: (cause) => {
if (!isUnauthorized(cause)) return;
animate(scope.current, { x: [...SHAKE.x] }, SHAKE.transition);
onReject?.(scope.current);
}
});
}} }}
> >
<FieldGroup> <FieldGroup>
@@ -71,6 +97,7 @@ export const LoginForm = () => {
onChange={(event) => { onChange={(event) => {
setToken(event.target.value); setToken(event.target.value);
if (error) reset(); if (error) reset();
onTyping?.(event.currentTarget);
}} }}
/> />
<InputGroupAddon align="inline-end"> <InputGroupAddon align="inline-end">
+74
View File
@@ -0,0 +1,74 @@
// 登录页视觉舞台
"use client";
import { useRef } from "react";
import { motion, type Variants } from "motion/react";
import { DotField, type DotFieldHandle } from "@/components/dot-field";
import { TypeText } from "@/components/type-text";
import { LOGIN_ENTER } from "@/constants/motion";
import { LoginForm } from "./login-form";
const stageVariants: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: LOGIN_ENTER.delayChildren,
staggerChildren: LOGIN_ENTER.stagger
}
}
};
const fadeVariants: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 1.2 } }
};
const riseVariants: Variants = {
hidden: { opacity: 0, y: LOGIN_ENTER.offsetY },
visible: { opacity: 1, y: 0, transition: LOGIN_ENTER.transition }
};
/**
* 登录舞台:交互点阵场与登录卡片同格叠放(不使用定位),
* 表单的输入、失败、成功通过回调转成点阵涟漪。
*/
export const LoginStage = () => {
const fieldRef = useRef<DotFieldHandle>(null);
return (
<motion.div
variants={stageVariants}
initial="hidden"
animate="visible"
// 单行单列且轨道为 minmax(0, 1fr):画布的像素尺寸不会反过来撑大布局
className="grid min-h-0 grid-cols-1 grid-rows-1 *:col-start-1 *:row-start-1"
>
<motion.div
variants={fadeVariants}
className="min-h-0 min-w-0 overflow-hidden"
>
<DotField ref={fieldRef} />
</motion.div>
<div className="flex min-h-0 flex-col items-center justify-center-safe gap-6 overflow-y-auto p-4">
<motion.div
variants={riseVariants}
className="flex w-full justify-center"
>
<LoginForm
onTyping={(input) => fieldRef.current?.ripple(input, "keystroke")}
onReject={(card) => fieldRef.current?.ripple(card, "reject")}
onAccept={(card) => fieldRef.current?.ripple(card, "accept")}
/>
</motion.div>
<motion.div variants={riseVariants}>
<TypeText
text="PRIVATE · SELF-HOSTED · POTPLAYER STREAM"
className="font-mono text-[0.65rem] tracking-[0.2em] text-muted-foreground"
/>
</motion.div>
</div>
</motion.div>
);
};
+5 -6
View File
@@ -1,17 +1,16 @@
// 登录页
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LoginForm } from "./components/login-form"; import { LoginStage } from "./components/login-stage";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "登录" title: "登录"
}; };
/** 外层只负责铺满视口与静态点阵;交互点阵与表单由 LoginStage 叠放 */
const LoginPage = () => ( const LoginPage = () => (
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-dot-grid p-4"> <main className="grid h-dvh grid-cols-1 grid-rows-1 overflow-hidden bg-dot-grid">
<LoginForm /> <LoginStage />
<p className="font-mono text-[0.65rem] tracking-[0.2em] text-muted-foreground">
PRIVATE · SELF-HOSTED · POTPLAYER STREAM
</p>
</main> </main>
); );
+27
View File
@@ -0,0 +1,27 @@
// 数字过渡动画组件
"use client";
import { useEffect } from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { NUMBER_TWEEN } from "@/constants/motion";
type AnimatedNumberProps = {
value: number;
/** 把补间中的数值格式化为文本;需为稳定引用(定义在模块顶层) */
format: (value: number) => string;
className?: string;
};
/** 数值变化时滚动到新值;直接更新 DOM 文本,不触发 React 渲染 */
export const AnimatedNumber = ({ value, format, className }: AnimatedNumberProps) => {
const current = useMotionValue(value);
const text = useTransform(current, format);
useEffect(() => {
const controls = animate(current, value, NUMBER_TWEEN);
return () => controls.stop();
}, [current, value]);
return <motion.span className={className}>{text}</motion.span>;
};
+38 -13
View File
@@ -1,16 +1,41 @@
// 品牌标识
"use client";
import { useRef } from "react";
import { BoxIcon } from "lucide-react"; import { BoxIcon } from "lucide-react";
/** TorBox 品牌字标:翠绿立方体 + 点阵 TORBOX + 等宽 ADMIN */ import { ScrambleText, type ScrambleTextHandle } from "@/components/scramble-text";
export const BrandMark = () => (
<div className="flex items-center gap-2.5"> type BrandMarkProps = {
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"> /** 挂载后自动解码一次的延迟(秒);不传则只在悬停时解码 */
<BoxIcon className="size-4.5" strokeWidth={2.25} aria-hidden="true" /> scrambleOnMount?: number;
</span> };
<span className="flex items-baseline gap-2">
<span className="font-display text-xl leading-none tracking-wider">TORBOX</span> /** TorBox 品牌字标:翠绿立方体 + 点阵 TORBOX + 等宽 ADMIN;悬停时字标重新解码 */
<span className="font-mono text-[0.65rem] leading-none tracking-[0.2em] text-muted-foreground"> export const BrandMark = ({ scrambleOnMount }: BrandMarkProps) => {
ADMIN const scrambleRef = useRef<ScrambleTextHandle>(null);
return (
<div
role="img"
aria-label="TorBox Admin"
className="flex items-center gap-2.5"
onPointerEnter={() => scrambleRef.current?.play()}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BoxIcon className="size-4.5" strokeWidth={2.25} aria-hidden="true" />
</span> </span>
</span> <span className="flex items-baseline gap-2">
</div> <ScrambleText
); ref={scrambleRef}
text="TORBOX"
playOnMount={scrambleOnMount}
className="font-display text-xl leading-none tracking-wider"
/>
<span className="font-mono text-[0.65rem] leading-none tracking-[0.2em] text-muted-foreground">
ADMIN
</span>
</span>
</div>
);
};
+88
View File
@@ -0,0 +1,88 @@
// 点阵背景动画组件
"use client";
import { useEffect, useImperativeHandle, useRef } from "react";
import { useReducedMotion } from "motion/react";
import { DOT_FIELD, RIPPLES, type RippleName, type RippleTone } from "@/constants/motion";
import { createDotField, type DotField as DotFieldEngine } from "@/lib/dot-field";
import { cn } from "@/lib/utils";
export type DotFieldHandle = {
/** 从元素中心或画布坐标扩散一次涟漪;动效关闭时立即 resolve */
ripple: (origin: Element | { x: number; y: number }, name: RippleName) => Promise<void>;
};
type DotFieldProps = {
ref?: React.Ref<DotFieldHandle>;
className?: string;
};
const TONE_VARS: Record<RippleTone, string> = {
primary: "--primary",
destructive: "--destructive"
};
const readColors = (element: HTMLElement) => {
const style = getComputedStyle(element);
return Object.fromEntries(
Object.entries(TONE_VARS).map(([tone, name]) => [tone, style.getPropertyValue(name).trim()])
) as Record<RippleTone, string>;
};
/**
* 交互点阵场:光标划过时点亮附近的点并留下余辉,可从任意位置触发涟漪。
* 只绘制被点亮的点,静态点阵由父元素的 bg-dot-grid 提供,两者网格对齐。
*/
export const DotField = ({ ref, className }: DotFieldProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<DotFieldEngine | null>(null);
const reduced = useReducedMotion();
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || reduced) return;
const engine = createDotField(canvas, { ...DOT_FIELD, colors: readColors(canvas) });
engineRef.current = engine;
const observer = new ResizeObserver(([entry]) => {
engine.resize(entry.contentRect.width, entry.contentRect.height, window.devicePixelRatio || 1);
});
observer.observe(canvas);
// 内容层盖在画布上方,因此监听 window 再换算到画布坐标
const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
engine.setPointer(event.clientX - rect.left, event.clientY - rect.top);
};
const onPointerLeave = () => engine.clearPointer();
window.addEventListener("pointermove", onPointerMove, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
return () => {
observer.disconnect();
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
engine.destroy();
engineRef.current = null;
};
}, [reduced]);
useImperativeHandle(ref, () => ({
ripple: (origin, name) => {
const engine = engineRef.current;
const canvas = canvasRef.current;
if (!engine || !canvas) return Promise.resolve();
const bounds = canvas.getBoundingClientRect();
let point = origin as { x: number; y: number };
if (origin instanceof Element) {
const rect = origin.getBoundingClientRect();
point = { x: rect.left + rect.width / 2 - bounds.left, y: rect.top + rect.height / 2 - bounds.top };
}
return engine.ripple(point, RIPPLES[name]);
}
}));
return <canvas ref={canvasRef} aria-hidden="true" className={cn("pointer-events-none block size-full", className)} />;
};
+29
View File
@@ -0,0 +1,29 @@
// 准星高亮框组件
"use client";
import { motion, type MotionStyle } from "motion/react";
import { cn } from "@/lib/utils";
type ReticleProps = {
ref?: React.Ref<HTMLDivElement>;
/** 由 useFollowHighlight 提供的位置、尺寸、透明度与缩放 */
style: MotionStyle;
className?: string;
};
/**
* 准星框:四个翠绿 L 角 + 极淡细线,跟随目标滑动。
* 需放在父元素左上角(单格 grid 叠放),不拦截指针事件。
*/
export const Reticle = ({ ref, style, className }: ReticleProps) => (
<motion.div
ref={ref}
aria-hidden="true"
style={style}
className={cn(
"pointer-events-none self-start justify-self-start rounded-sm bg-corner-brackets ring-1 ring-primary/15",
className
)}
/>
);
+66
View File
@@ -0,0 +1,66 @@
// 乱码解码文字动画组件
"use client";
import { useEffect, useImperativeHandle, useRef } from "react";
import { animate, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
import { SCRAMBLE } from "@/constants/motion";
import { scrambleFrame } from "@/utils/scramble";
export type ScrambleTextHandle = { play: () => void };
type ScrambleTextProps = {
ref?: React.Ref<ScrambleTextHandle>;
text: string;
/** 挂载后自动播放一次,值为延迟(秒);不传则不自动播放 */
playOnMount?: number;
duration?: number;
charset?: string;
className?: string;
};
/** 乱码解码文字:字符先随机跳动,再从左到右依次定格。直接写 DOM 文本,不触发渲染 */
export const ScrambleText = ({
ref,
text,
playOnMount,
duration = SCRAMBLE.duration,
charset = SCRAMBLE.charset,
className
}: ScrambleTextProps) => {
const spanRef = useRef<HTMLSpanElement>(null);
const controlsRef = useRef<AnimationPlaybackControls | null>(null);
const reduced = useReducedMotion();
const play = (delay = 0) => {
const element = spanRef.current;
if (!element || reduced) return;
controlsRef.current?.stop();
controlsRef.current = animate(0, 1, {
duration,
delay,
ease: "linear",
onUpdate: (progress) => {
element.textContent = scrambleFrame(text, progress, charset);
},
onComplete: () => {
element.textContent = text;
}
});
};
useImperativeHandle(ref, () => ({ play: () => play() }));
useEffect(() => {
if (playOnMount !== undefined) play(playOnMount);
return () => controlsRef.current?.stop();
// 只在挂载时自动播放
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<span ref={spanRef} className={className}>
{text}
</span>
);
};
+49
View File
@@ -0,0 +1,49 @@
// 打字机文字动画组件
"use client";
import { useEffect } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { TYPEWRITER } from "@/constants/motion";
import { cn } from "@/lib/utils";
type TypeTextProps = {
text: string;
/** 开始打字前的延迟(秒) */
delay?: number;
className?: string;
};
/** 逐字打出的装饰性文字,末尾带闪烁光标;对读屏隐藏 */
export const TypeText = ({ text, delay = TYPEWRITER.delay, className }: TypeTextProps) => {
const reduced = useReducedMotion();
const count = useMotionValue(0);
const visible = useTransform(count, (value) => text.slice(0, Math.round(value)));
useEffect(() => {
if (reduced) {
count.jump(text.length);
return;
}
const controls = animate(count, text.length, {
duration: text.length * TYPEWRITER.perChar,
delay,
ease: "linear"
});
return () => controls.stop();
}, [count, text, delay, reduced]);
return (
<p aria-hidden="true" className={cn("whitespace-pre", className)}>
<motion.span>{visible}</motion.span>
<motion.span
className="text-primary"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={TYPEWRITER.cursorBlink}
>
_
</motion.span>
</p>
);
};
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "cn"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "cn"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
+2
View File
@@ -1,3 +1,5 @@
// 登录相关常量
/** 登录态 cookie:保存用户输入的 ACCESS_TOKEN,httpOnly 仅服务端可读 */ /** 登录态 cookie:保存用户输入的 ACCESS_TOKEN,httpOnly 仅服务端可读 */
export const SESSION_COOKIE = "torbox_admin_token"; export const SESSION_COOKIE = "torbox_admin_token";
+131
View File
@@ -0,0 +1,131 @@
// 动效参数常量
/**
* 动效参数集中在这里调整,组件与 hook 只引用常量。
* Motion 的时长单位为秒;DOM 计时(setTimeout)单位为毫秒,命名带 _MS。
*/
/** 准星(卡片跟随框) */
export const RETICLE = {
/** 框比卡片向外扩出的像素 */
outset: 4,
/** 鼠标离开卡片后多久隐藏;穿过卡片间隙时在此时间内命中下一张,直接滑过去 */
hideDelayMs: 120,
/** 位置与尺寸的弹簧 */
spring: { stiffness: 520, damping: 42, mass: 0.9 },
/** 出现时从略大收紧到卡片上的「锁定」效果 */
enterScale: 1.06
} as const;
/** 视频封面上跟随鼠标的点阵光斑 */
export const SPOTLIGHT = {
radius: 72,
fade: { duration: 0.2 }
} as const;
/** 卡片入场:同一行按列依次浮现 */
export const CARD_ENTER = {
stagger: 0.035,
offsetY: 8,
transition: { duration: 0.32, ease: [0.22, 1, 0.36, 1] }
} as const;
/** 工具栏数字滚动 */
export const NUMBER_TWEEN = {
duration: 0.45,
ease: [0.22, 1, 0.36, 1]
} as const;
/** NEW 标记的呼吸点 */
export const BREATHE = {
duration: 1.8,
repeat: Infinity,
ease: "easeInOut"
} as const;
/** 字标乱码解码 */
export const SCRAMBLE = {
duration: 0.7,
/** 登录页入场时延后播放,等卡片浮现 */
mountDelay: 0.35,
/** 点阵字符集 */
charset: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/"
} as const;
/** 登录页入场编排:点阵场 → 卡片 → 标语 */
export const LOGIN_ENTER = {
delayChildren: 0.1,
stagger: 0.12,
offsetY: 16,
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] }
} as const;
/** 标语打字 */
export const TYPEWRITER = {
/** 每个字符的时长 */
perChar: 0.028,
delay: 0.7,
cursorBlink: { duration: 0.55, repeat: Infinity, repeatType: "reverse" }
} as const;
/** 登录卡片在密钥错误时的抖动 */
export const SHAKE = {
x: [0, -10, 10, -6, 6, -2, 0],
transition: { duration: 0.42, ease: "easeOut" }
} as const;
/** 点阵场:与 CSS bg-dot-grid 的 14px 间距保持一致 */
export const DOT_FIELD = {
spacing: 14,
/** radial-gradient 点位在每个格子中心 */
offset: 7,
/** 光标点亮半径(px) */
hoverRadius: 120,
/** 余辉衰减时间常数(ms) */
decayMs: 350,
/** 点亮后的最大半径(px) */
litRadius: 2.2,
baseRadius: 1
} as const;
export type RippleTone = "primary" | "destructive";
export type RipplePreset = {
tone: RippleTone;
/** 扩散到最大半径的时长(秒) */
duration: number;
/** 环带宽度(px) */
width: number;
strength: number;
/** null 表示扩散到画布对角线 */
maxRadius: number | null;
/** 连续圈数与间隔(秒) */
rings?: { count: number; gap: number };
};
export const RIPPLES = {
keystroke: {
tone: "primary",
duration: 0.55,
width: 16,
strength: 0.55,
maxRadius: 360
},
reject: {
tone: "destructive",
duration: 0.9,
width: 36,
strength: 1,
maxRadius: 900,
rings: { count: 2, gap: 0.14 }
},
accept: {
tone: "primary",
duration: 0.75,
width: 140,
strength: 1,
maxRadius: null
}
} as const satisfies Record<string, RipplePreset>;
export type RippleName = keyof typeof RIPPLES;
+78 -5
View File
@@ -1,3 +1,4 @@
// TorBox 相关常量
import type { SourceType } from "@/types/torbox"; import type { SourceType } from "@/types/torbox";
export const TORBOX_API = "https://api.torbox.app/v1/api"; export const TORBOX_API = "https://api.torbox.app/v1/api";
@@ -9,16 +10,88 @@ export const SOURCES = [
] as const; ] as const;
export const IMAGE_EXT = new Set([ export const IMAGE_EXT = new Set([
"jpg", "jpeg", "png", "gif", "webp", "avif", "apng", "svg", "svgz", "ico", "jpg",
"bmp", "tif", "tiff", "tga", "jxl", "heic", "heif" "jpeg",
"png",
"gif",
"webp",
"avif",
"apng",
"svg",
"svgz",
"ico",
"bmp",
"tif",
"tiff",
"tga",
"jxl",
"heic",
"heif"
]); ]);
export const VIDEO_EXT = new Set([ export const VIDEO_EXT = new Set([
"mp4", "webm", "ogv", "ogg", "m4v", "mov", "avi", "mkv", "wmv", "flv", "f4v", "mp4",
"mpg", "mpeg", "mpe", "ts", "m2ts", "mts", "vob", "3gp", "3g2", "m3u8", "mpd", "webm",
"rm", "rmvb", "asf", "divx", "qt", "h264", "h265", "hevc", "av1" "ogv",
"ogg",
"m4v",
"mov",
"avi",
"mkv",
"wmv",
"flv",
"f4v",
"mpg",
"mpeg",
"mpe",
"ts",
"m2ts",
"mts",
"vob",
"3gp",
"3g2",
"m3u8",
"mpd",
"rm",
"rmvb",
"asf",
"divx",
"qt",
"h264",
"h265",
"hevc",
"av1"
]); ]);
/** 多久内加入的资源标记为 NEW */
export const NEW_ITEM_WINDOW_MS = 24 * 60 * 60 * 1000;
/** 距过期多久开始提示「即将过期」 */
export const EXPIRING_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
/** 距过期不足多久时提示升级为醒目的红色 */
export const EXPIRING_URGENT_MS = 24 * 60 * 60 * 1000;
/**
* TorBox 下载状态的中文名(torrent 沿用 qBittorrent 状态),未列出的状态显示「处理中」。
* 参考 https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#torrent-management
*/
export const DOWNLOAD_STATE_LABELS: Record<string, string> = {
downloading: "下载中",
forcedDL: "下载中",
"stalled (no seeds)": "无种子",
stalledDL: "等待连接",
metaDL: "获取元数据",
queuedDL: "排队中",
paused: "已暂停",
pausedDL: "已暂停",
checkingDL: "校验中",
checkingResumeData: "校验中",
allocating: "分配空间",
moving: "移动中",
error: "出错",
missingFiles: "文件缺失"
};
export const SOURCE_LABELS: Record<SourceType, string> = { export const SOURCE_LABELS: Record<SourceType, string> = {
torrents: "Torrent", torrents: "Torrent",
usenet: "Usenet", usenet: "Usenet",
+10 -2
View File
@@ -1,13 +1,21 @@
// 登录与退出登录 hook
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { login, logout } from "@/services/auth"; import { login, logout } from "@/services/auth";
export const useLogin = () => { type UseLoginOptions = {
/** 登录成功、跳转前执行(如播放过场动效),返回 Promise 时等待其完成 */
beforeNavigate?: () => Promise<void> | void;
};
export const useLogin = ({ beforeNavigate }: UseLoginOptions = {}) => {
const router = useRouter(); const router = useRouter();
return useMutation({ return useMutation({
mutationFn: login, mutationFn: login,
onSuccess: () => { onSuccess: async () => {
router.prefetch("/");
await beforeNavigate?.();
router.replace("/"); router.replace("/");
router.refresh(); router.refresh();
} }
+1
View File
@@ -1,3 +1,4 @@
// 复制文本并提示的 hook
import { useCallback } from "react"; import { useCallback } from "react";
import { message } from "@/lib/message"; import { message } from "@/lib/message";
+9 -2
View File
@@ -1,8 +1,13 @@
// 监听元素内容宽度的 hook
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
const getContentWidth = (node: HTMLElement) => { const getContentWidth = (node: HTMLElement) => {
const style = getComputedStyle(node); const style = getComputedStyle(node);
return node.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); return (
node.clientWidth -
parseFloat(style.paddingLeft) -
parseFloat(style.paddingRight)
);
}; };
/** 元素内容区宽度(不含内边距),挂载时同步测量,之后随 ResizeObserver 更新 */ /** 元素内容区宽度(不含内边距),挂载时同步测量,之后随 ResizeObserver 更新 */
@@ -11,7 +16,9 @@ export const useElementWidth = <T extends HTMLElement>() => {
const ref = useCallback((node: T | null) => { const ref = useCallback((node: T | null) => {
if (!node) return; if (!node) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); const observer = new ResizeObserver(([entry]) =>
setWidth(entry.contentRect.width)
);
observer.observe(node); observer.observe(node);
setWidth(getContentWidth(node)); setWidth(getContentWidth(node));
return () => observer.disconnect(); return () => observer.disconnect();
+177
View File
@@ -0,0 +1,177 @@
// 高亮框跟随目标元素的 hook
import { useEffect, useMemo, useRef } from "react";
import { useReducedMotion, useSpring, type SpringOptions } from "motion/react";
import { getLayoutRectWithin } from "@/utils/layout";
type Geometry = { x: number; y: number; width: number; height: number };
const GEOMETRY_KEYS = ["x", "y", "width", "height"] as const;
type FollowHighlightOptions = {
/** 可被跟随的目标,如 "[data-file-card]" */
targetSelector: string;
/** 离开目标后多久隐藏(ms);在此期间命中新目标则直接滑过去 */
hideDelayMs: number;
/** 高亮框比目标向外扩出的像素 */
outset?: number;
spring?: SpringOptions;
/** 出现时的初始缩放,>1 表示从大到小「锁定」 */
enterScale?: number;
};
/**
* 跟随高亮:鼠标悬停或键盘聚焦到容器内的目标时,高亮框滑到目标上;离开后隐藏。
* 只产出 MotionValue,不触发 React 渲染;外观由使用方决定。
* 高亮框需放在其父元素左上角(如单格 grid 叠放),父元素即坐标原点。
*/
export const useFollowHighlight = <T extends HTMLElement>(
containerRef: React.RefObject<HTMLElement | null>,
{
targetSelector,
hideDelayMs,
outset = 0,
spring,
enterScale = 1
}: FollowHighlightOptions
) => {
const ref = useRef<T>(null);
/** 当前跟随的目标、其几何与待执行的隐藏计时器(事件中读写,不参与渲染) */
const stateRef = useRef<{
target: HTMLElement | null;
geometry?: Geometry;
timer?: ReturnType<typeof setTimeout>;
}>({ target: null });
const reduced = useReducedMotion();
const x = useSpring(0, spring);
const y = useSpring(0, spring);
const width = useSpring(0, spring);
const height = useSpring(0, spring);
const opacity = useSpring(0, spring);
const scale = useSpring(enterScale, spring);
const api = useMemo(() => {
const state = stateRef.current;
const cancelHide = () => clearTimeout(state.timer);
const measure = (target: HTMLElement, origin: HTMLElement): Geometry => {
const rect = getLayoutRectWithin(target, origin);
return {
x: rect.x - outset,
y: rect.y - outset,
width: rect.width + outset * 2,
height: rect.height + outset * 2
};
};
const apply = (geometry: Geometry, instant: boolean) => {
state.geometry = geometry;
for (const key of GEOMETRY_KEYS) {
const value = { x, y, width, height }[key];
if (instant) value.jump(geometry[key]);
else value.set(geometry[key]);
}
};
const moveTo = (target: HTMLElement) => {
cancelHide();
const origin = ref.current?.parentElement;
if (!origin || target === state.target) return;
// 从隐藏状态出现时原地出现,不从上一个位置飞过来
const instant = state.target === null || Boolean(reduced);
state.target = target;
apply(measure(target, origin), instant);
if (instant && !reduced) scale.jump(enterScale);
opacity.set(1);
scale.set(1);
};
const hide = () => {
cancelHide();
state.target = null;
opacity.set(0);
scale.set(enterScale);
};
/**
* 目标自身发生位移(如虚拟列表重新测量行高)时重新对齐:直接贴上去,不播弹簧;
* 目标已被卸载则隐藏。可在每次布局变化后调用,几何未变时不做任何事。
*/
const refresh = () => {
const { target } = state;
const origin = ref.current?.parentElement;
if (!target || !origin) return;
if (!target.isConnected) {
hide();
return;
}
const next = measure(target, origin);
if (GEOMETRY_KEYS.some((key) => next[key] !== state.geometry?.[key]))
apply(next, true);
};
const scheduleHide = () => {
cancelHide();
state.timer = setTimeout(hide, hideDelayMs);
};
return { moveTo, hide, refresh, scheduleHide, cancelHide };
}, [
x,
y,
width,
height,
opacity,
scale,
outset,
enterScale,
hideDelayMs,
reduced
]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const findTarget = (node: EventTarget | null) =>
node instanceof Element
? node.closest<HTMLElement>(targetSelector)
: null;
const onPointerOver = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
const target = findTarget(event.target);
if (target && container.contains(target)) api.moveTo(target);
else api.scheduleHide();
};
const onPointerLeave = () => api.scheduleHide();
const onFocusIn = (event: FocusEvent) => {
const element = event.target as HTMLElement;
const target = findTarget(element);
if (target && element.matches(":focus-visible")) api.moveTo(target);
};
const onFocusOut = (event: FocusEvent) => {
if (!findTarget(event.relatedTarget)) api.scheduleHide();
};
container.addEventListener("pointerover", onPointerOver);
container.addEventListener("pointerleave", onPointerLeave);
container.addEventListener("focusin", onFocusIn);
container.addEventListener("focusout", onFocusOut);
return () => {
api.cancelHide();
container.removeEventListener("pointerover", onPointerOver);
container.removeEventListener("pointerleave", onPointerLeave);
container.removeEventListener("focusin", onFocusIn);
container.removeEventListener("focusout", onFocusOut);
};
}, [containerRef, targetSelector, api]);
return {
ref,
style: { x, y, width, height, opacity, scale },
moveTo: api.moveTo,
hide: api.hide,
refresh: api.refresh
};
};
+94
View File
@@ -0,0 +1,94 @@
// 网格键盘方向键导航 hook
import { useEffect, useRef } from "react";
import { KEY_TO_DIRECTION, moveInRows } from "@/utils/grid-nav";
type GridKeyboardNavOptions = {
/** 网格单元,如 "[data-file-card]" */
cellSelector: string;
/** 单元内接收焦点的元素 */
focusSelector: string;
/** 行元素,需带数字型 data-index(虚拟列表的行) */
rowSelector: string;
rowCount: number;
getCellCount: (row: number) => number;
/** 目标行可能尚未渲染,先滚动过去 */
scrollToRow: (row: number) => void;
};
/** 最多等待几帧让目标行渲染出来 */
const MAX_FOCUS_FRAMES = 10;
/**
* 方向键在网格单元间移动焦点,支持跨行、跳过非单元行、目标未渲染时先滚动。
* 只在焦点位于单元内时接管方向键。
*/
export const useGridKeyboardNav = (
containerRef: React.RefObject<HTMLElement | null>,
options: GridKeyboardNavOptions
) => {
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let frameId = 0;
const focusCell = (row: number, col: number, attempt = 0) => {
const { cellSelector, focusSelector, rowSelector } = optionsRef.current;
const rowElement = container.querySelector(
`${rowSelector}[data-index="${row}"]`
);
const cell = rowElement?.querySelectorAll(cellSelector)[col];
const target = cell?.querySelector<HTMLElement>(focusSelector);
if (target) {
target.focus({ preventScroll: true });
return;
}
if (attempt < MAX_FOCUS_FRAMES) {
frameId = requestAnimationFrame(() => focusCell(row, col, attempt + 1));
}
};
const onKeyDown = (event: KeyboardEvent) => {
const direction = KEY_TO_DIRECTION[event.key];
if (
!direction ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
)
return;
const { cellSelector, rowSelector, rowCount, getCellCount, scrollToRow } =
optionsRef.current;
const cell = (event.target as Element).closest(cellSelector);
const rowElement = cell?.closest<HTMLElement>(
`${rowSelector}[data-index]`
);
if (!cell || !rowElement) return;
const row = Number(rowElement.dataset.index);
const col = Array.from(rowElement.querySelectorAll(cellSelector)).indexOf(
cell
);
const next = moveInRows(getCellCount, rowCount, { row, col }, direction);
if (!next) return;
event.preventDefault();
cancelAnimationFrame(frameId);
scrollToRow(next.row);
focusCell(next.row, next.col);
};
container.addEventListener("keydown", onKeyDown);
return () => {
cancelAnimationFrame(frameId);
container.removeEventListener("keydown", onKeyDown);
};
}, [containerRef]);
};
+35
View File
@@ -0,0 +1,35 @@
// 全局快捷键 hook
import { useEffect, useRef } from "react";
type HotkeyOptions = {
/** 焦点在输入框、文本域或可编辑元素中时不触发(默认 true) */
ignoreEditable?: boolean;
};
const isEditable = (target: EventTarget | null) =>
target instanceof HTMLElement &&
(target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
/** 全局单键快捷键,如 "/";带修饰键的组合不触发 */
export const useHotkey = (
key: string,
handler: (event: KeyboardEvent) => void,
{ ignoreEditable = true }: HotkeyOptions = {}
) => {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== key || event.ctrlKey || event.metaKey || event.altKey)
return;
if (ignoreEditable && isEditable(event.target)) return;
handlerRef.current(event);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [key, ignoreEditable]);
};
+68 -10
View File
@@ -1,10 +1,21 @@
import { useMemo } from "react"; // 资源库数据查询与筛选 hook
import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { NEW_ITEM_WINDOW_MS } from "@/constants/torbox";
import { getLibrary } from "@/services/library"; import { getLibrary } from "@/services/library";
import { useLibraryFilterStore, type SourceFilter } from "@/stores/library-filter-store"; import {
useLibraryFilterStore,
type SourceFilter
} from "@/stores/library-filter-store";
import type { LibraryFile, LibraryItem } from "@/types/torbox"; import type { LibraryFile, LibraryItem } from "@/types/torbox";
import { getBaseName, getFileExt, getMediaKind, type MediaKind } from "@/utils/media"; import { isWithin, msUntil } from "@/utils/date";
import {
getBaseName,
getFileExt,
getMediaKind,
type MediaKind
} from "@/utils/media";
export type LibraryFileView = LibraryFile & { export type LibraryFileView = LibraryFile & {
/** 列表展示名(short_name 优先) */ /** 列表展示名(short_name 优先) */
@@ -16,15 +27,41 @@ export type LibraryFileView = LibraryFile & {
export type LibraryItemView = Omit<LibraryItem, "files"> & { export type LibraryItemView = Omit<LibraryItem, "files"> & {
files: LibraryFileView[]; files: LibraryFileView[];
totalSize: number; totalSize: number;
/** 在本次拉取前 NEW_ITEM_WINDOW_MS 内加入 */
isNew: boolean;
/** 距过期的剩余毫秒数(相对本次拉取时间,已过期为负数);无过期时间时为 null */
expiresInMs: number | null;
}; };
const LIBRARY_QUERY_KEY = ["library"] as const;
export const useLibrary = () => export const useLibrary = () =>
useQuery({ useQuery({
queryKey: ["library"], queryKey: LIBRARY_QUERY_KEY,
queryFn: getLibrary, queryFn: () => getLibrary(),
retry: false retry: false
}); });
/**
* 手动刷新:绕过 TorBox 列表缓存拉取最新数据,结果写回同一个查询。
* 自动重新获取(窗口聚焦等)仍走缓存,减少对 TorBox 的请求。
*/
export const useRefreshLibrary = () => {
const queryClient = useQueryClient();
return useCallback(
() =>
queryClient
.fetchQuery({
queryKey: LIBRARY_QUERY_KEY,
queryFn: () => getLibrary({ fresh: true }),
staleTime: 0
})
// 失败提示已由 axios 拦截器统一处理,查询本身会进入错误状态
.catch(() => undefined),
[queryClient]
);
};
const sumSize = (files: LibraryFile[]) => const sumSize = (files: LibraryFile[]) =>
files.reduce((total, file) => total + (file.size ?? 0), 0); files.reduce((total, file) => total + (file.size ?? 0), 0);
@@ -48,12 +85,30 @@ export const useFilteredLibrary = () => {
const files = item.files const files = item.files
.map((file) => { .map((file) => {
const displayName = getBaseName(file.short_name ?? file.name); const displayName = getBaseName(file.short_name ?? file.name);
return { ...file, displayName, ext: getFileExt(displayName), kind: getMediaKind(displayName) }; return {
...file,
displayName,
ext: getFileExt(displayName),
kind: getMediaKind(displayName, file.mimetype)
};
}) })
.sort(byKindThenName); .sort(byKindThenName);
return { ...item, files, totalSize: sumSize(files) }; // 以数据拉取时间为「现在」,保持渲染纯净
const isNew = isWithin(
item.createdAt,
NEW_ITEM_WINDOW_MS,
query.dataUpdatedAt
);
const expiresInMs = msUntil(item.expiresAt, query.dataUpdatedAt);
return {
...item,
files,
totalSize: sumSize(files),
isNew,
expiresInMs
};
}), }),
[query.data] [query.data, query.dataUpdatedAt]
); );
const view = useMemo(() => { const view = useMemo(() => {
@@ -79,7 +134,10 @@ export const useFilteredLibrary = () => {
}; };
for (const item of matched) sourceCounts[item.source] += 1; for (const item of matched) sourceCounts[item.source] += 1;
const visible = source === "all" ? matched : matched.filter((item) => item.source === source); const visible =
source === "all"
? matched
: matched.filter((item) => item.source === source);
return { return {
items: visible, items: visible,
+1
View File
@@ -1,3 +1,4 @@
// 获取当前站点 origin 的 hook
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
const subscribe = () => () => {}; const subscribe = () => () => {};
+45
View File
@@ -0,0 +1,45 @@
// 聚光灯跟随效果 hook
import { useMemo } from "react";
import {
animate,
useMotionTemplate,
useMotionValue,
type ValueAnimationTransition
} from "motion/react";
type SpotlightOptions = {
/** 光斑半径(px) */
radius: number;
fade?: ValueAnimationTransition<number>;
};
/**
* 元素级光斑:返回跟随鼠标的径向遮罩与透明度,用于给某一层「打光」。
* 只写 MotionValue,不触发 React 渲染。
*/
export const useSpotlight = ({ radius, fade }: SpotlightOptions) => {
const x = useMotionValue(0);
const y = useMotionValue(0);
const opacity = useMotionValue(0);
const maskImage = useMotionTemplate`radial-gradient(circle ${radius}px at ${x}px ${y}px, #000, transparent)`;
const handlers = useMemo(
() => ({
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType === "touch") return;
const rect = event.currentTarget.getBoundingClientRect();
x.set(event.clientX - rect.left);
y.set(event.clientY - rect.top);
},
onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType !== "touch") animate(opacity, 1, fade);
},
onPointerLeave: () => {
animate(opacity, 0, fade);
}
}),
[x, y, opacity, fade]
);
return { maskImage, opacity, handlers };
};
+1
View File
@@ -1,3 +1,4 @@
// 服务端启动钩子(环境变量校验)
export async function register() { export async function register() {
// 仅在 Node.js 服务端运行时校验,启动失败即暴露配置问题 // 仅在 Node.js 服务端运行时校验,启动失败即暴露配置问题
if (process.env.NEXT_RUNTIME === "nodejs") { if (process.env.NEXT_RUNTIME === "nodejs") {
+1
View File
@@ -1,3 +1,4 @@
// Axios 请求实例与拦截器
import axios from "axios"; import axios from "axios";
import { LOGIN_API } from "@/constants/auth"; import { LOGIN_API } from "@/constants/auth";
import { message } from "./message"; import { message } from "./message";
+250
View File
@@ -0,0 +1,250 @@
// 点阵背景动画引擎
import {
animate,
cancelFrame,
frame,
type AnimationPlaybackControls,
type FrameData
} from "motion";
import type { RipplePreset, RippleTone } from "@/constants/motion";
export type DotFieldConfig = {
/** 点间距与首个点的偏移,需与 CSS 点阵背景一致 */
spacing: number;
offset: number;
hoverRadius: number;
decayMs: number;
baseRadius: number;
litRadius: number;
colors: Record<RippleTone, string>;
};
export type DotField = {
resize: (width: number, height: number, dpr: number) => void;
/** 光标位置(画布坐标),连续调用会沿轨迹点亮 */
setPointer: (x: number, y: number) => void;
clearPointer: () => void;
/** 从 origin 扩散一圈(或多圈)涟漪,全部播完后 resolve */
ripple: (
origin: { x: number; y: number },
preset: RipplePreset
) => Promise<void>;
destroy: () => void;
};
type Ring = {
x: number;
y: number;
radius: number;
maxRadius: number;
preset: RipplePreset;
};
const TONES: RippleTone[] = ["primary", "destructive"];
/** 透明度分档数:同档同色的点合并成一条路径绘制 */
const LEVELS = 6;
const THRESHOLD = 0.03;
/**
* 画布点阵场:只绘制被点亮的点,底层静态点由 CSS 背景负责。
* 光标与涟漪向能量场注入能量,能量按指数衰减形成余辉;无能量时停止帧循环。
*/
export const createDotField = (
canvas: HTMLCanvasElement,
config: DotFieldConfig
): DotField => {
const context = canvas.getContext("2d");
const { spacing, offset } = config;
let width = 0;
let height = 0;
let cols = 0;
let rows = 0;
let energy = new Float32Array(0);
let tone = new Uint8Array(0);
let running = false;
let last: { x: number; y: number } | null = null;
const trail: { x: number; y: number }[] = [];
const rings = new Set<Ring>();
const animations = new Set<AnimationPlaybackControls>();
/** 在 (cx, cy) 周围 reach 范围内按 falloff(距离) 注入能量 */
const inject = (
cx: number,
cy: number,
reach: number,
toneIndex: number,
falloff: (d: number) => number
) => {
const c0 = Math.max(0, Math.floor((cx - reach - offset) / spacing));
const c1 = Math.min(cols - 1, Math.ceil((cx + reach - offset) / spacing));
const r0 = Math.max(0, Math.floor((cy - reach - offset) / spacing));
const r1 = Math.min(rows - 1, Math.ceil((cy + reach - offset) / spacing));
for (let row = r0; row <= r1; row += 1) {
const dy = offset + row * spacing - cy;
for (let col = c0; col <= c1; col += 1) {
const value = falloff(Math.hypot(offset + col * spacing - cx, dy));
const index = row * cols + col;
if (value > energy[index]) {
energy[index] = value;
tone[index] = toneIndex;
}
}
}
};
const hoverFalloff = (d: number) =>
d < config.hoverRadius ? (1 - d / config.hoverRadius) ** 2 : 0;
/** 沿光标轨迹按半径一半的步长插值注入,快速划过也能连成一条 */
const injectTrail = () => {
const step = config.hoverRadius / 2;
for (const point of trail) {
const from = last ?? point;
const steps = Math.max(
1,
Math.ceil(Math.hypot(point.x - from.x, point.y - from.y) / step)
);
for (let s = 1; s <= steps; s += 1) {
const t = s / steps;
inject(
from.x + (point.x - from.x) * t,
from.y + (point.y - from.y) * t,
config.hoverRadius,
0,
hoverFalloff
);
}
last = point;
}
trail.length = 0;
};
const injectRing = (ring: Ring) => {
if (ring.radius < 1) return;
const half = ring.preset.width / 2;
const fade = 1 - ring.radius / ring.maxRadius;
const toneIndex = TONES.indexOf(ring.preset.tone);
inject(ring.x, ring.y, ring.radius + half, toneIndex, (d) => {
const gap = Math.abs(d - ring.radius);
return gap < half ? ring.preset.strength * (1 - gap / half) * fade : 0;
});
};
const draw = () => {
if (!context) return;
context.clearRect(0, 0, width, height);
const buckets: number[][] = Array.from(
{ length: TONES.length * LEVELS },
() => []
);
let active = false;
for (let index = 0; index < energy.length; index += 1) {
const value = energy[index];
if (value < THRESHOLD) continue;
active = true;
const level = Math.min(LEVELS, Math.ceil(value * LEVELS));
buckets[tone[index] * LEVELS + level - 1].push(index);
}
buckets.forEach((indices, bucket) => {
if (indices.length === 0) return;
const level = (bucket % LEVELS) + 1;
const radius =
config.baseRadius +
((config.litRadius - config.baseRadius) * level) / LEVELS;
context.globalAlpha = level / LEVELS;
context.fillStyle = config.colors[TONES[Math.floor(bucket / LEVELS)]];
context.beginPath();
for (const index of indices) {
const x = offset + (index % cols) * spacing;
const y = offset + Math.floor(index / cols) * spacing;
context.moveTo(x + radius, y);
context.arc(x, y, radius, 0, Math.PI * 2);
}
context.fill();
});
context.globalAlpha = 1;
return active;
};
const tick = ({ delta }: FrameData) => {
const decay = Math.exp(-delta / config.decayMs);
for (let index = 0; index < energy.length; index += 1)
energy[index] *= decay;
injectTrail();
rings.forEach(injectRing);
const active = draw();
if (!active && rings.size === 0 && trail.length === 0) stop();
};
const start = () => {
if (running || !context) return;
running = true;
frame.render(tick, true);
};
const stop = () => {
running = false;
cancelFrame(tick);
};
return {
resize: (nextWidth, nextHeight, dpr) => {
width = nextWidth;
height = nextHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context?.setTransform(dpr, 0, 0, dpr, 0, 0);
cols = Math.max(0, Math.ceil((width - offset) / spacing));
rows = Math.max(0, Math.ceil((height - offset) / spacing));
energy = new Float32Array(cols * rows);
tone = new Uint8Array(cols * rows);
},
setPointer: (x, y) => {
trail.push({ x, y });
start();
},
clearPointer: () => {
last = null;
},
ripple: (origin, preset) => {
const maxRadius =
preset.maxRadius ??
Math.hypot(
Math.max(origin.x, width - origin.x),
Math.max(origin.y, height - origin.y)
) + preset.width;
const count = preset.rings?.count ?? 1;
const gap = preset.rings?.gap ?? 0;
start();
const runs = Array.from({ length: count }, (_, index) => {
const ring: Ring = { ...origin, radius: 0, maxRadius, preset };
rings.add(ring);
const controls = animate(0, maxRadius, {
duration: preset.duration,
delay: index * gap,
ease: "easeOut",
onUpdate: (radius) => {
ring.radius = radius;
}
});
animations.add(controls);
return new Promise<void>((resolve) => {
controls.then(() => {
rings.delete(ring);
animations.delete(controls);
resolve();
});
});
});
return Promise.all(runs).then(() => undefined);
},
destroy: () => {
stop();
animations.forEach((controls) => controls.stop());
animations.clear();
rings.clear();
}
};
};
+1
View File
@@ -1,3 +1,4 @@
// 业务消息提示入口
import { toast } from "@/components/ui/toast"; import { toast } from "@/components/ui/toast";
type MessageOptions = { type MessageOptions = {
+1
View File
@@ -1,3 +1,4 @@
// TanStack Query 客户端工厂
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
export function makeQueryClient() { export function makeQueryClient() {
+13
View File
@@ -0,0 +1,13 @@
// 全局动效配置 Provider
"use client";
import { MotionConfig } from "motion/react";
/** 全局动效配置:系统开启「减弱动态效果」时关闭位移类动画,保留透明度过渡 */
export default function MotionProvider({
children
}: {
children: React.ReactNode;
}) {
return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}
+1
View File
@@ -1,3 +1,4 @@
// TanStack Query Provider
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
+5 -2
View File
@@ -1,3 +1,4 @@
// 页面访问鉴权拦截
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/constants/auth"; import { SESSION_COOKIE } from "@/constants/auth";
import { verifyAccessToken } from "@/server/auth"; import { verifyAccessToken } from "@/server/auth";
@@ -9,8 +10,10 @@ export function proxy(req: NextRequest) {
const authed = verifyAccessToken(req.cookies.get(SESSION_COOKIE)?.value); const authed = verifyAccessToken(req.cookies.get(SESSION_COOKIE)?.value);
const isLoginPage = req.nextUrl.pathname === LOGIN_PATH; const isLoginPage = req.nextUrl.pathname === LOGIN_PATH;
if (!authed && !isLoginPage) return NextResponse.redirect(new URL(LOGIN_PATH, req.url)); if (!authed && !isLoginPage)
if (authed && isLoginPage) return NextResponse.redirect(new URL("/", req.url)); return NextResponse.redirect(new URL(LOGIN_PATH, req.url));
if (authed && isLoginPage)
return NextResponse.redirect(new URL("/", req.url));
return NextResponse.next(); return NextResponse.next();
} }
+8 -2
View File
@@ -1,3 +1,4 @@
// 服务端鉴权工具
import { timingSafeEqual } from "node:crypto"; import { timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/constants/auth"; import { SESSION_COOKIE } from "@/constants/auth";
@@ -10,7 +11,9 @@ const safeEqual = (a: string, b: string) => {
}; };
/** 校验传入值是否等于环境变量 ACCESS_TOKEN */ /** 校验传入值是否等于环境变量 ACCESS_TOKEN */
export const verifyAccessToken = (value: string | null | undefined): value is string => export const verifyAccessToken = (
value: string | null | undefined
): value is string =>
Boolean(value) && safeEqual(value!, serverEnv("ACCESS_TOKEN")); Boolean(value) && safeEqual(value!, serverEnv("ACCESS_TOKEN"));
/** 校验登录 cookie、Bearer 头或 ?token=(供 PotPlayer 等外部播放器使用)任一有效 */ /** 校验登录 cookie、Bearer 头或 ?token=(供 PotPlayer 等外部播放器使用)任一有效 */
@@ -25,7 +28,10 @@ export const isAuthorized = (req: NextRequest) => {
}; };
export const jsonError = (status: number, detail: string) => export const jsonError = (status: number, detail: string) =>
Response.json({ success: false, detail }, { status, headers: { "Cache-Control": "no-store" } }); Response.json(
{ success: false, detail },
{ status, headers: { "Cache-Control": "no-store" } }
);
/** 统一入口:返回 Response 表示已拒绝,返回 null 表示通过 */ /** 统一入口:返回 Response 表示已拒绝,返回 null 表示通过 */
export const guard = (req: NextRequest) => { export const guard = (req: NextRequest) => {
+4 -1
View File
@@ -1,3 +1,4 @@
// 服务端环境变量校验
const REQUIRED_ENV = ["ACCESS_TOKEN", "TORBOX_API_KEY"] as const; const REQUIRED_ENV = ["ACCESS_TOKEN", "TORBOX_API_KEY"] as const;
type RequiredEnv = (typeof REQUIRED_ENV)[number]; type RequiredEnv = (typeof REQUIRED_ENV)[number];
@@ -6,7 +7,9 @@ type RequiredEnv = (typeof REQUIRED_ENV)[number];
export const assertServerEnv = () => { export const assertServerEnv = () => {
const missing = REQUIRED_ENV.filter((name) => !process.env[name]?.trim()); const missing = REQUIRED_ENV.filter((name) => !process.env[name]?.trim());
if (missing.length > 0) { if (missing.length > 0) {
throw new Error(`缺少必要的环境变量:${missing.join("、")}。请在 .env.local 或容器环境中配置后重启`); throw new Error(
`缺少必要的环境变量:${missing.join("、")}。请在 .env.local 或容器环境中配置后重启`
);
} }
}; };
+1
View File
@@ -1,3 +1,4 @@
// 服务端登录态读取
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SESSION_COOKIE } from "@/constants/auth"; import { SESSION_COOKIE } from "@/constants/auth";
+122 -19
View File
@@ -1,9 +1,50 @@
// TorBox API 服务端封装
import { serverEnv } from "@/server/env"; import { serverEnv } from "@/server/env";
import { SOURCES, TORBOX_API } from "@/constants/torbox"; import { SOURCES, TORBOX_API } from "@/constants/torbox";
import type { LibraryItem, LibraryResponse, SourceType } from "@/types/torbox"; import type {
DownloadStatus,
LibraryItem,
LibraryResponse,
SourceType
} from "@/types/torbox";
type RawFile = { id: number; name: string; short_name?: string; size?: number }; type RawFile = {
type RawItem = { id: number; name: string; files?: RawFile[] }; id: number;
name: string;
short_name?: string;
size?: number;
mimetype?: string | null;
};
type RawItem = {
id: number;
name: string;
created_at?: string | null;
expires_at?: string | null;
download_state?: string | null;
download_finished?: boolean;
download_present?: boolean;
progress?: number;
files?: RawFile[];
};
type GetLibraryOptions = {
/** 绕过 TorBox 的列表缓存(torrent 列表默认每 600 秒才更新),仅在用户手动刷新时使用 */
bypassCache?: boolean;
};
/** 字段缺失时按可用处理,避免接口变动导致整列表不可播放 */
const getDownloadStatus = (item: RawItem): DownloadStatus => {
if (item.download_finished === false) return "downloading";
if (item.download_present === false) return "unavailable";
return "ready";
};
/** 进度统一为 0–1;兼容百分比形式 */
const toProgressRatio = (value: number | undefined) => {
if (value == null || !Number.isFinite(value)) return 0;
const ratio = value > 1 ? value / 100 : value;
return Math.min(1, Math.max(0, ratio));
};
const apiKey = () => serverEnv("TORBOX_API_KEY"); const apiKey = () => serverEnv("TORBOX_API_KEY");
@@ -25,27 +66,89 @@ const torboxFetch = async (path: string) => {
export const isSourceType = (value: string): value is SourceType => export const isSourceType = (value: string): value is SourceType =>
SOURCES.some((source) => source.type === value); SOURCES.some((source) => source.type === value);
export const getLibrary = async (): Promise<LibraryResponse> => { /** TorBox 列表接口单页条数(接口默认与上限均为 1000) */
const PAGE_SIZE = 1000;
/** 翻页上限:防止接口忽略 offset 或数据异常时无限请求 */
const MAX_PAGES = 50;
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : "unknown";
/**
* 按 offset 翻页拉取某个来源的全部资源。
* - 返回不足一页即视为到达末尾
* - 按 id 去重:翻页期间有新增资源导致的重复不会出现两次;整页都是重复时说明接口忽略了 offset,直接停止
* - 中途失败时保留已拉到的部分,并通过 error 说明
*/
const fetchAllPages = async (
list: string,
bypassCache: boolean
): Promise<{ items: RawItem[]; error?: string }> => {
const items: RawItem[] = [];
const seen = new Set<number>();
for (let page = 0; page < MAX_PAGES; page += 1) {
const query = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(page * PAGE_SIZE)
});
if (bypassCache) query.set("bypass_cache", "true");
let data: RawItem[];
try {
data = (await torboxFetch(`${list}?${query}`)) ?? [];
} catch (error) {
if (items.length === 0) return { items, error: errorMessage(error) };
return {
items,
error: `已加载前 ${items.length} 条,后续拉取失败:${errorMessage(error)}`
};
}
const fresh = data.filter((item) => !seen.has(item.id));
for (const item of fresh) seen.add(item.id);
items.push(...fresh);
if (data.length < PAGE_SIZE || fresh.length === 0) return { items };
}
return {
items,
error: `资源超过 ${PAGE_SIZE * MAX_PAGES} 条,仅显示前 ${items.length} 条`
};
};
export const getLibrary = async ({
bypassCache = false
}: GetLibraryOptions = {}): Promise<LibraryResponse> => {
const errors: LibraryResponse["errors"] = {}; const errors: LibraryResponse["errors"] = {};
const items: LibraryItem[] = []; const items: LibraryItem[] = [];
// 三个来源并行,各自内部按页顺序拉取
await Promise.all( await Promise.all(
SOURCES.map(async ({ type, list }) => { SOURCES.map(async ({ type, list }) => {
try { const { items: data, error } = await fetchAllPages(list, bypassCache);
const data: RawItem[] = (await torboxFetch(`${list}?limit=1000`)) ?? []; if (error) errors[type] = error;
for (const item of data) { for (const item of data) {
items.push({ items.push({
id: item.id, id: item.id,
name: item.name, name: item.name,
source: type, source: type,
files: (item.files ?? []).map((file) => ({ createdAt: item.created_at ?? null,
...file, expiresAt: item.expires_at ?? null,
playUrl: `/api/stream/${type}/${item.id}/${file.id}` status: getDownloadStatus(item),
})) progress: toProgressRatio(item.progress),
}); downloadState: item.download_state ?? null,
} // 只下发前端需要的字段,s3_path、absolute_path 等内部信息不出服务端
} catch (error) { files: (item.files ?? []).map((file) => ({
errors[type] = error instanceof Error ? error.message : "unknown"; id: file.id,
name: file.name,
short_name: file.short_name,
size: file.size,
mimetype: file.mimetype ?? null,
playUrl: `/api/stream/${type}/${item.id}/${file.id}`
}))
});
} }
}) })
); );
+1
View File
@@ -1,3 +1,4 @@
// 认证相关 API 请求
import { LOGIN_API } from "@/constants/auth"; import { LOGIN_API } from "@/constants/auth";
import BaseClientService from "@/lib/axios"; import BaseClientService from "@/lib/axios";
+11 -4
View File
@@ -1,9 +1,16 @@
// 资源库 API 请求
import BaseClientService from "@/lib/axios"; import BaseClientService from "@/lib/axios";
import type { LibraryResponse } from "@/types/torbox"; import type { LibraryResponse } from "@/types/torbox";
export const getLibrary = async () => { type GetLibraryOptions = {
const { data } = await BaseClientService.get<{ success: boolean; data: LibraryResponse }>( /** 绕过 TorBox 列表缓存拿最新数据,仅用于用户手动刷新 */
"/library" fresh?: boolean;
); };
export const getLibrary = async ({ fresh = false }: GetLibraryOptions = {}) => {
const { data } = await BaseClientService.get<{
success: boolean;
data: LibraryResponse;
}>("/library", { params: fresh ? { fresh: 1 } : undefined });
return data.data; return data.data;
}; };
+1
View File
@@ -1,3 +1,4 @@
// 资源列表筛选状态 store
import { create } from "zustand"; import { create } from "zustand";
import type { SourceType } from "@/types/torbox"; import type { SourceType } from "@/types/torbox";
+1
View File
@@ -1,3 +1,4 @@
// 资源列表视图状态 store(滚动位置等)
import type { VirtualItem } from "@tanstack/react-virtual"; import type { VirtualItem } from "@tanstack/react-virtual";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
+20
View File
@@ -1,3 +1,4 @@
// TorBox 业务类型
export type SourceType = "torrents" | "usenet" | "webdl"; export type SourceType = "torrents" | "usenet" | "webdl";
export type LibraryFile = { export type LibraryFile = {
@@ -5,14 +6,33 @@ export type LibraryFile = {
name: string; name: string;
short_name?: string; short_name?: string;
size?: number; size?: number;
/** TorBox 识别的 MIME 类型,如 video/x-matroska;缺失时为 null */
mimetype: string | null;
/** 服务端生成的相对播放路径,不含 token */ /** 服务端生成的相对播放路径,不含 token */
playUrl: string; playUrl: string;
}; };
/**
* 资源是否可播放:
* - ready:已下载完成且文件仍在 TorBox 服务器上
* - downloading:尚未下载完成
* - unavailable:已完成但文件已不在服务器上(过期或被清理)
*/
export type DownloadStatus = "ready" | "downloading" | "unavailable";
export type LibraryItem = { export type LibraryItem = {
id: number; id: number;
name: string; name: string;
source: SourceType; source: SourceType;
/** TorBox 返回的创建时间(UTC ISO),缺失时为 null */
createdAt: string | null;
/** TorBox 返回的过期时间(UTC ISO),过期后文件会被清理;缺失时为 null */
expiresAt: string | null;
status: DownloadStatus;
/** 下载进度 0–1 */
progress: number;
/** TorBox 原始下载状态,如 downloading、paused、stalled (no seeds) */
downloadState: string | null;
files: LibraryFile[]; files: LibraryFile[];
}; };
+2
View File
@@ -1,3 +1,5 @@
// 剪贴板复制函数
/** 兼容非安全上下文(局域网 http 部署时 navigator.clipboard 不可用)的复制 */ /** 兼容非安全上下文(局域网 http 部署时 navigator.clipboard 不可用)的复制 */
export const copyText = async (text: string) => { export const copyText = async (text: string) => {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
+38
View File
@@ -0,0 +1,38 @@
// 时间处理函数
/** ISO 时间是否落在 now 之前的 windowMs 范围内;无效时间返回 false */
export const isWithin = (
iso: string | null | undefined,
windowMs: number,
now: number
) => {
if (!iso) return false;
const time = Date.parse(iso);
if (Number.isNaN(time)) return false;
return time <= now && now - time <= windowMs;
};
/** 距 ISO 时间还有多少毫秒(已过去为负数);无效时间返回 null */
export const msUntil = (iso: string | null | undefined, now: number) => {
if (!iso) return null;
const time = Date.parse(iso);
return Number.isNaN(time) ? null : time - now;
};
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** 粗略时长:满 1 天按天、满 1 小时按小时,否则按分钟(至少 1 分钟) */
export const formatDuration = (ms: number) => {
if (ms >= DAY) return `${Math.floor(ms / DAY)} 天`;
if (ms >= HOUR) return `${Math.floor(ms / HOUR)} 小时`;
return `${Math.max(1, Math.floor(ms / MINUTE))} 分钟`;
};
/** 本地化的日期时间,如 2026/10/05 14:30 */
export const formatDateTime = (iso: string) =>
new Date(iso).toLocaleString("zh-CN", {
dateStyle: "short",
timeStyle: "short"
});
+1
View File
@@ -1,3 +1,4 @@
// 格式化函数
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]; const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
/** 以 1024 进制格式化字节数,如 4.3 GB;未知大小返回 "—" */ /** 以 1024 进制格式化字节数,如 4.3 GB;未知大小返回 "—" */
+44
View File
@@ -0,0 +1,44 @@
// 网格方向导航计算函数
export type GridDirection = "left" | "right" | "up" | "down";
export type GridPosition = { row: number; col: number };
/**
* 在「每行单元数不同、部分行没有单元」的行序列中移动。
* - 左右:行内移动,越界时跨到上一个/下一个有单元的行
* - 上下:跳过没有单元的行,保持列位置,目标行较短时取最后一列
* 无处可去时返回 null。
*/
export const moveInRows = (
getCellCount: (row: number) => number,
rowCount: number,
from: GridPosition,
direction: GridDirection
): GridPosition | null => {
const step = direction === "left" || direction === "up" ? -1 : 1;
const findRow = (start: number) => {
for (let row = start; row >= 0 && row < rowCount; row += step) {
if (getCellCount(row) > 0) return row;
}
return null;
};
if (direction === "left" || direction === "right") {
const col = from.col + step;
if (col >= 0 && col < getCellCount(from.row)) return { row: from.row, col };
const row = findRow(from.row + step);
if (row === null) return null;
return { row, col: step > 0 ? 0 : getCellCount(row) - 1 };
}
const row = findRow(from.row + step);
if (row === null) return null;
return { row, col: Math.min(from.col, getCellCount(row) - 1) };
};
export const KEY_TO_DIRECTION: Record<string, GridDirection> = {
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down"
};
+32
View File
@@ -0,0 +1,32 @@
// 元素布局位置测量函数
/**
* 元素在文档中的布局位置:沿 offsetParent 累加,不受 transform 与祖先滚动影响。
* 两个元素的结果相减即得相对位置,适合在入场动画进行中测量。
*/
export const getLayoutOffset = (element: HTMLElement) => {
let x = 0;
let y = 0;
let node: HTMLElement | null = element;
while (node) {
x += node.offsetLeft;
y += node.offsetTop;
node = node.offsetParent as HTMLElement | null;
}
return { x, y };
};
/** target 相对 origin 的布局矩形(未经 transform) */
export const getLayoutRectWithin = (
target: HTMLElement,
origin: HTMLElement
) => {
const a = getLayoutOffset(target);
const b = getLayoutOffset(origin);
return {
x: a.x - b.x,
y: a.y - b.y,
width: target.offsetWidth,
height: target.offsetHeight
};
};
+12 -2
View File
@@ -1,3 +1,4 @@
// 媒体文件类型与路径处理函数
import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox"; import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox";
export type MediaKind = "video" | "image" | "other"; export type MediaKind = "video" | "image" | "other";
@@ -12,7 +13,15 @@ export const getFileExt = (name: string) => {
return dot > 0 ? base.slice(dot + 1).toLowerCase() : ""; return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
}; };
export const getMediaKind = (name: string): MediaKind => { /**
* 判断文件类别:TorBox 给出的 mimetype 为 video/* 或 image/* 时直接采用;
* 缺失、为通用二进制类型(application/octet-stream)或其他类型时按扩展名兜底。
* 例如 rmvb 常被识别为 application/vnd.rn-realmedia,按扩展名仍归为视频。
*/
export const getMediaKind = (name: string, mimetype?: string | null): MediaKind => {
const type = mimetype?.trim().toLowerCase() ?? "";
if (type.startsWith("video/")) return "video";
if (type.startsWith("image/")) return "image";
const ext = getFileExt(name); const ext = getFileExt(name);
if (VIDEO_EXT.has(ext)) return "video"; if (VIDEO_EXT.has(ext)) return "video";
if (IMAGE_EXT.has(ext)) return "image"; if (IMAGE_EXT.has(ext)) return "image";
@@ -28,7 +37,8 @@ export const buildStreamUrl = (
) => { ) => {
const url = new URL(playUrl, origin); const url = new URL(playUrl, origin);
const base = fileName.split(/[\\/]/).pop(); const base = fileName.split(/[\\/]/).pop();
if (base && base !== "." && base !== "..") url.pathname += "/" + encodeURIComponent(base); if (base && base !== "." && base !== "..")
url.pathname += "/" + encodeURIComponent(base);
url.searchParams.set("token", token); url.searchParams.set("token", token);
return url.toString(); return url.toString();
}; };
+23
View File
@@ -0,0 +1,23 @@
// 乱码动画帧生成函数
/**
* 乱码解码的一帧:progress 0→1,字符从左到右依次定格,未定格的字符随机替换。
* 空白字符保持原样,避免字距跳动。
*/
export const scrambleFrame = (
text: string,
progress: number,
charset: string,
random: () => number = Math.random
) => {
const settled = Math.floor(Math.min(1, Math.max(0, progress)) * text.length);
let output = "";
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
output +=
index < settled || /\s/.test(char)
? char
: charset[Math.floor(random() * charset.length)];
}
return output;
};