feat: 交互及样式优化
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.3.2",
|
||||
"lucide-react": "^1.47.0",
|
||||
"motion": "^14.0.0",
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
|
||||
Generated
+54
@@ -29,6 +29,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^1.47.0
|
||||
version: 1.47.0(react@19.2.8)
|
||||
motion:
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next:
|
||||
specifier: 16.3.5
|
||||
version: 16.3.5(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -1679,6 +1682,17 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
framer-motion@14.0.0:
|
||||
resolution: {integrity: sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2338,6 +2352,23 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
motion-dom@14.0.0:
|
||||
resolution: {integrity: sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==}
|
||||
|
||||
motion-utils@14.0.0:
|
||||
resolution: {integrity: sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==}
|
||||
|
||||
motion@14.0.0:
|
||||
resolution: {integrity: sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -5002,6 +5033,15 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
framer-motion@14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
motion-dom: 14.0.0
|
||||
motion-utils: 14.0.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-extra@11.4.0:
|
||||
@@ -5575,6 +5615,20 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
motion-dom@14.0.0:
|
||||
dependencies:
|
||||
motion-utils: 14.0.0
|
||||
|
||||
motion-utils@14.0.0: {}
|
||||
|
||||
motion@14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
framer-motion: 14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 应用顶栏
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +1,113 @@
|
||||
// 文件卡片
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CopyIcon, DownloadIcon, EyeIcon, FileIcon, ImageOffIcon, PlayIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
CircleOffIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
HourglassIcon,
|
||||
ImageOffIcon,
|
||||
PlayIcon,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip";
|
||||
import { SPOTLIGHT } from "@/constants/motion";
|
||||
import { useCopy } from "@/hooks/use-copy";
|
||||
import type { LibraryFileView } from "@/hooks/use-library";
|
||||
import { useOrigin } from "@/hooks/use-origin";
|
||||
import { useSpotlight } from "@/hooks/use-spotlight";
|
||||
import { formatBytes } from "@/utils/format";
|
||||
import { buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
|
||||
import type { DownloadStatus } from "@/types/torbox";
|
||||
import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media";
|
||||
|
||||
import { CARD_FOCUS_PROPS } from "./library-dom";
|
||||
|
||||
const ExtLabel = ({ ext }: { ext: string }) => (
|
||||
<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">
|
||||
<Icon className="size-6" strokeWidth={1.5} aria-hidden="true" />
|
||||
<ExtLabel ext={ext} />
|
||||
</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 copy = useCopy();
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
@@ -34,30 +115,54 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
|
||||
|
||||
if (!origin) return null;
|
||||
|
||||
const streamUrl = buildStreamUrl(origin, file.playUrl, file.displayName, token);
|
||||
const streamUrl = buildStreamUrl(
|
||||
origin,
|
||||
file.playUrl,
|
||||
file.displayName,
|
||||
token
|
||||
);
|
||||
const potPlayerUrl = toPotPlayerUrl(streamUrl);
|
||||
|
||||
const available = status === "ready";
|
||||
let cover: React.ReactNode;
|
||||
let primaryAction: React.ReactNode;
|
||||
|
||||
if (file.kind === "video") {
|
||||
if (!available) {
|
||||
const action = PENDING_ACTION[file.kind];
|
||||
cover = (
|
||||
<a
|
||||
href={potPlayerUrl}
|
||||
title="用 PotPlayer 播放"
|
||||
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>
|
||||
<PlaceholderCover
|
||||
ext={file.ext}
|
||||
icon={status === "downloading" ? HourglassIcon : CircleOffIcon}
|
||||
/>
|
||||
);
|
||||
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" />
|
||||
PotPlayer
|
||||
</Button>
|
||||
@@ -83,7 +188,13 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
|
||||
</button>
|
||||
);
|
||||
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" />
|
||||
预览
|
||||
</Button>
|
||||
@@ -97,6 +208,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
|
||||
className="flex-1"
|
||||
render={<a href={streamUrl} download={file.displayName} />}
|
||||
nativeButton={false}
|
||||
{...CARD_FOCUS_PROPS}
|
||||
>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
下载
|
||||
@@ -105,7 +217,7 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
|
||||
}
|
||||
|
||||
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}
|
||||
<CardContent className="flex flex-1 flex-col gap-1 py-3">
|
||||
<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"
|
||||
size="icon-sm"
|
||||
aria-label="复制直链"
|
||||
disabled={!available}
|
||||
onClick={() => copy(streamUrl, "已复制直链")}
|
||||
/>
|
||||
}
|
||||
@@ -134,11 +247,13 @@ export const FileCard = ({ file, token }: { file: LibraryFileView; token: string
|
||||
</Tooltip>
|
||||
</CardFooter>
|
||||
|
||||
{file.kind === "image" && (
|
||||
{available && file.kind === "image" && (
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate pr-8">{file.displayName}</DialogTitle>
|
||||
<DialogTitle className="truncate pr-8">
|
||||
{file.displayName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
|
||||
@@ -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";
|
||||
|
||||
const COLUMN_CLASS: Record<number, string> = {
|
||||
@@ -7,17 +13,51 @@ const COLUMN_CLASS: Record<number, string> = {
|
||||
5: "grid-cols-5"
|
||||
};
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: CARD_ENTER.stagger } }
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: CARD_ENTER.offsetY },
|
||||
visible: { opacity: 1, y: 0, transition: CARD_ENTER.transition }
|
||||
};
|
||||
|
||||
type FileGridProps = {
|
||||
columns?: number;
|
||||
/** 挂载时子项按顺序依次浮现,子项需为 FileGridItem */
|
||||
animated?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 等宽卡片网格:规范中唯一允许的 grid 布局,其余一律 flex;最多 5 列。
|
||||
* 传入 columns 时使用固定列数(虚拟列表按容器宽度计算),否则按视口断点 2→3→4→5 列。
|
||||
*/
|
||||
export const FileGrid = ({ columns, children }: { columns?: number; children: React.ReactNode }) => (
|
||||
<div
|
||||
export const FileGrid = ({ columns, animated, children }: FileGridProps) => (
|
||||
<motion.div
|
||||
variants={gridVariants}
|
||||
initial={animated ? "hidden" : false}
|
||||
animate="visible"
|
||||
className={cn(
|
||||
"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}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
// 资源条目标题行
|
||||
"use client";
|
||||
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
@@ -8,6 +9,9 @@ import { SOURCE_LABELS } from "@/constants/torbox";
|
||||
import type { LibraryItemView } from "@/hooks/use-library";
|
||||
import { formatBytes, formatCount } from "@/utils/format";
|
||||
|
||||
import { ExpiryBadge } from "./expiry-badge";
|
||||
import { ItemDownloadStatus } from "./item-download-status";
|
||||
import { NewBadge } from "./new-badge";
|
||||
import { SourceDot } from "./source-dot";
|
||||
|
||||
type ItemHeaderProps = {
|
||||
@@ -34,9 +38,12 @@ export const ItemHeader = ({ item, expanded, onToggle }: ItemHeaderProps) => (
|
||||
<SourceDot source={item.source} />
|
||||
{SOURCE_LABELS[item.source]}
|
||||
</Badge>
|
||||
{item.isNew && <NewBadge createdAt={item.createdAt} />}
|
||||
<h2 className="min-w-0 flex-1 truncate font-medium" title={item.name}>
|
||||
{item.name}
|
||||
</h2>
|
||||
<ItemDownloadStatus item={item} />
|
||||
<ExpiryBadge item={item} />
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{formatCount(item.files.length)} 文件
|
||||
<span className="hidden sm:inline"> · {formatBytes(item.totalSize)}</span>
|
||||
|
||||
@@ -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]";
|
||||
@@ -1,21 +1,42 @@
|
||||
// 资源列表(虚拟滚动)
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
|
||||
import { Reticle } from "@/components/reticle";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { RETICLE } from "@/constants/motion";
|
||||
import { SOURCE_LABELS } from "@/constants/torbox";
|
||||
import { useElementWidth } from "@/hooks/use-element-width";
|
||||
import { useFollowHighlight } from "@/hooks/use-follow-highlight";
|
||||
import { useGridKeyboardNav } from "@/hooks/use-grid-keyboard-nav";
|
||||
import type { LibraryItemView } from "@/hooks/use-library";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLibraryFilterStore } from "@/stores/library-filter-store";
|
||||
import { useLibraryViewStore, type LibraryScrollSnapshot } from "@/stores/library-view-store";
|
||||
import {
|
||||
useLibraryViewStore,
|
||||
type LibraryScrollSnapshot
|
||||
} from "@/stores/library-view-store";
|
||||
import type { SourceType } from "@/types/torbox";
|
||||
|
||||
import { FileCard } from "./file-card";
|
||||
import { FileGrid } from "./file-grid";
|
||||
import { FileGrid, FileGridItem } from "./file-grid";
|
||||
import { ItemHeader } from "./item-header";
|
||||
import {
|
||||
CARD_FOCUS_SELECTOR,
|
||||
CARD_PROPS,
|
||||
CARD_SELECTOR,
|
||||
ROW_SELECTOR
|
||||
} from "./library-dom";
|
||||
import {
|
||||
GRID_GAP,
|
||||
buildLibraryRows,
|
||||
@@ -61,10 +82,13 @@ const getRestoreOffset = (
|
||||
snapshot: LibraryScrollSnapshot,
|
||||
estimate: (index: number) => number
|
||||
) => {
|
||||
const sizes = new Map(snapshot.measurements.map((item) => [String(item.key), item.size]));
|
||||
const sizes = new Map(
|
||||
snapshot.measurements.map((item) => [String(item.key), item.size])
|
||||
);
|
||||
let offset = PADDING_START;
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
if (rows[index].key === snapshot.anchorKey) return Math.max(0, offset + snapshot.anchorDelta);
|
||||
if (rows[index].key === snapshot.anchorKey)
|
||||
return Math.max(0, offset + snapshot.anchorDelta);
|
||||
offset += sizes.get(rows[index].key) ?? estimate(index);
|
||||
}
|
||||
return 0;
|
||||
@@ -88,7 +112,13 @@ export const LibraryList = (props: LibraryListProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListProps & { width: number }) => {
|
||||
const VirtualRows = ({
|
||||
scrollRef,
|
||||
items,
|
||||
notices,
|
||||
token,
|
||||
width
|
||||
}: LibraryListProps & { width: number }) => {
|
||||
const columns = getColumnCount(width);
|
||||
const cardWidth = (width - GRID_GAP * (columns - 1)) / columns;
|
||||
|
||||
@@ -129,7 +159,8 @@ const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListPro
|
||||
paddingEnd: PADDING_END,
|
||||
anchorTo: atTop ? "start" : "end",
|
||||
initialMeasurementsCache: restore?.measurements,
|
||||
initialOffset: () => (restore ? getRestoreOffset(rows, restore, estimateSize) : 0),
|
||||
initialOffset: () =>
|
||||
restore ? getRestoreOffset(rows, restore, estimateSize) : 0,
|
||||
onChange: (instance) => {
|
||||
setAtTop((instance.scrollOffset ?? 0) <= 1);
|
||||
// 每次滚动停止时保存快照
|
||||
@@ -158,50 +189,88 @@ const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListPro
|
||||
[virtualizer, saveSnapshot]
|
||||
);
|
||||
|
||||
// 交互层:都挂在同一个容器上做事件委托,彼此独立,删掉一处调用即可拆掉
|
||||
const containerRef = useRef<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 totalSize = virtualizer.getTotalSize();
|
||||
const first = virtualRows[0];
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* 不使用定位:可见行处于正常文档流,上下用占位块撑出未渲染部分的高度 */}
|
||||
<div aria-hidden="true" style={{ height: first ? first.start : 0 }} />
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className={rowClassName(row)}
|
||||
>
|
||||
{row.type === "notice" && (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>{SOURCE_LABELS[row.source]} 拉取失败</AlertTitle>
|
||||
<AlertDescription>{row.detail}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{row.type === "header" && (
|
||||
<ItemHeader
|
||||
item={row.item}
|
||||
expanded={row.expanded}
|
||||
onToggle={() => toggleCollapsed(row.itemKey)}
|
||||
/>
|
||||
)}
|
||||
{row.type === "cards" && (
|
||||
<FileGrid columns={columns}>
|
||||
{row.files.map((file) => (
|
||||
<FileCard key={file.id} file={file} token={token} />
|
||||
))}
|
||||
</FileGrid>
|
||||
)}
|
||||
{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 }} />
|
||||
// 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动,不使用定位
|
||||
<div ref={containerRef} className="grid *:col-start-1 *:row-start-1">
|
||||
<div className="flex flex-col">
|
||||
{/* 不使用定位:可见行处于正常文档流,上下用占位块撑出未渲染部分的高度 */}
|
||||
<div aria-hidden="true" style={{ height: first ? first.start : 0 }} />
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className={rowClassName(row)}
|
||||
>
|
||||
{row.type === "notice" && (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>{SOURCE_LABELS[row.source]} 拉取失败</AlertTitle>
|
||||
<AlertDescription>{row.detail}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{row.type === "header" && (
|
||||
<ItemHeader
|
||||
item={row.item}
|
||||
expanded={row.expanded}
|
||||
onToggle={() => toggleCollapsed(row.itemKey)}
|
||||
/>
|
||||
)}
|
||||
{row.type === "cards" && (
|
||||
<FileGrid columns={columns} animated>
|
||||
{row.files.map((file) => (
|
||||
<FileGridItem key={file.id} {...CARD_PROPS}>
|
||||
<FileCard file={file} token={token} status={row.status} />
|
||||
</FileGridItem>
|
||||
))}
|
||||
</FileGrid>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 资源列表行数据构建与网格布局计算
|
||||
import type { LibraryFileView, LibraryItemView } from "@/hooks/use-library";
|
||||
import type { SourceType } from "@/types/torbox";
|
||||
import type { DownloadStatus, SourceType } from "@/types/torbox";
|
||||
|
||||
/** 卡片网格参数:与 FileGrid 的 gap-3 保持一致 */
|
||||
export const GRID_GAP = 12;
|
||||
@@ -10,17 +11,33 @@ const MAX_COLUMNS = 5;
|
||||
export const getColumnCount = (width: number) =>
|
||||
Math.min(
|
||||
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 =
|
||||
| { 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 };
|
||||
|
||||
export const getItemKey = (item: LibraryItemView) => `${item.source}-${item.id}`;
|
||||
export const getItemKey = (item: LibraryItemView) =>
|
||||
`${item.source}-${item.id}`;
|
||||
|
||||
/**
|
||||
* 把分组数据拍平成行。行 key 只依赖资源与分块序号、不依赖列数,
|
||||
@@ -42,7 +59,14 @@ export const buildLibraryRows = (
|
||||
for (const item of items) {
|
||||
const itemKey = getItemKey(item);
|
||||
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 (item.files.length === 0) {
|
||||
@@ -53,7 +77,8 @@ export const buildLibraryRows = (
|
||||
rows.push({
|
||||
type: "cards",
|
||||
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 { FileGrid } from "./file-grid";
|
||||
|
||||
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) => (
|
||||
<div key={section} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// 资源列表工具栏(搜索、筛选、刷新)
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { AnimatedNumber } from "@/components/animated-number";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -9,10 +12,16 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput
|
||||
} from "@/components/ui/input-group";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
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 { useHotkey } from "@/hooks/use-hotkey";
|
||||
import {
|
||||
useLibraryFilterStore,
|
||||
type KindFilter,
|
||||
@@ -22,6 +31,9 @@ import { formatBytes, formatCount } from "@/utils/format";
|
||||
|
||||
import { SourceDot } from "./source-dot";
|
||||
|
||||
// 数字滚动过程中的格式化:计数取整,大小按 1024 进制
|
||||
const formatInteger = (value: number) => formatCount(Math.round(value));
|
||||
|
||||
const KIND_OPTIONS: { value: KindFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
@@ -49,6 +61,13 @@ export const LibraryToolbar = ({
|
||||
const setSource = useLibraryFilterStore((state) => state.setSource);
|
||||
const setKind = useLibraryFilterStore((state) => state.setKind);
|
||||
|
||||
// "/" 聚焦搜索;搜索框内 Esc 先清空,已为空则失焦
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
useHotkey("/", (event) => {
|
||||
event.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
});
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -57,17 +76,30 @@ export const LibraryToolbar = ({
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
ref={searchRef}
|
||||
placeholder="搜索资源或文件名"
|
||||
aria-keyshortcuts="/"
|
||||
value={keyword}
|
||||
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">
|
||||
<InputGroupButton size="icon-xs" aria-label="清除搜索" onClick={() => setKeyword("")}>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{keyword ? (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="清除搜索"
|
||||
onClick={() => setKeyword("")}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
) : (
|
||||
<Kbd>/</Kbd>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
{/* 单选:再次点击已选项时保持当前值,不允许清空 */}
|
||||
@@ -77,17 +109,27 @@ export const LibraryToolbar = ({
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={[source]}
|
||||
onValueChange={(value) => value[0] && setSource(value[0] as SourceFilter)}
|
||||
onValueChange={(value) =>
|
||||
value[0] && setSource(value[0] as SourceFilter)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{SOURCES.map(({ type }) => (
|
||||
<ToggleGroupItem key={type} value={type}>
|
||||
<SourceDot source={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>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
@@ -109,8 +151,9 @@ export const LibraryToolbar = ({
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{formatCount(summary.items)} 资源 · {formatCount(summary.files)} 文件 ·{" "}
|
||||
{formatBytes(summary.size)}
|
||||
<AnimatedNumber value={summary.items} format={formatInteger} /> 资源
|
||||
· <AnimatedNumber value={summary.files} format={formatInteger} />{" "}
|
||||
文件 · <AnimatedNumber value={summary.size} format={formatBytes} />
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// 资源库视图(数据获取与状态分发)
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "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 {
|
||||
Empty,
|
||||
@@ -14,7 +20,7 @@ import {
|
||||
EmptyTitle
|
||||
} from "@/components/ui/empty";
|
||||
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 type { SourceType } from "@/types/torbox";
|
||||
|
||||
@@ -23,7 +29,9 @@ import { LibrarySkeleton } from "./library-skeleton";
|
||||
import { LibraryToolbar } from "./library-toolbar";
|
||||
|
||||
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,7 +49,9 @@ export const LibraryView = ({ token }: { token: string }) => {
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>资源加载失败</AlertTitle>
|
||||
<AlertDescription>请检查服务端 TORBOX_API_KEY 配置或网络连接</AlertDescription>
|
||||
<AlertDescription>
|
||||
请检查服务端 TORBOX_API_KEY 配置或网络连接
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button size="sm" variant="outline" onClick={() => query.refetch()}>
|
||||
重试
|
||||
@@ -70,7 +80,9 @@ export const LibraryView = ({ token }: { token: string }) => {
|
||||
<FolderOpenIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>账号里还没有资源</EmptyTitle>
|
||||
<EmptyDescription>在 TorBox 添加下载任务后,点击刷新即可在这里播放</EmptyDescription>
|
||||
<EmptyDescription>
|
||||
在 TorBox 添加下载任务后,点击刷新即可在这里播放
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
@@ -80,7 +92,9 @@ export const LibraryView = ({ token }: { token: string }) => {
|
||||
<SearchXIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>没有匹配的文件</EmptyTitle>
|
||||
<EmptyDescription>换个关键词,或调整来源与类型筛选</EmptyDescription>
|
||||
<EmptyDescription>
|
||||
换个关键词,或调整来源与类型筛选
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{isFiltered && (
|
||||
<EmptyContent>
|
||||
@@ -101,13 +115,20 @@ export const LibraryView = ({ token }: { token: string }) => {
|
||||
sourceCounts={sourceCounts}
|
||||
summary={summary}
|
||||
isFetching={query.isFetching}
|
||||
onRefresh={() => query.refetch()}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{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>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// 退出登录按钮
|
||||
"use client";
|
||||
|
||||
import { LogOutIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
|
||||
export const LogoutButton = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -1,6 +1,12 @@
|
||||
// 播放列表菜单
|
||||
"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 {
|
||||
@@ -22,7 +28,9 @@ export const PlaylistMenu = ({ token }: { token: string }) => {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" disabled={!playlistUrl} />}>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" disabled={!playlistUrl} />}
|
||||
>
|
||||
<ListVideoIcon data-icon="inline-start" />
|
||||
播放列表
|
||||
<ChevronDownIcon data-icon="inline-end" />
|
||||
@@ -30,11 +38,14 @@ export const PlaylistMenu = ({ token }: { token: string }) => {
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>全部资源 · M3U</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => window.location.assign(toPotPlayerUrl(playlistUrl))}>
|
||||
<PlayIcon />
|
||||
用 PotPlayer 打开
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.location.assign(toPotPlayerUrl(playlistUrl))}
|
||||
>
|
||||
<PlayIcon />用 PotPlayer 打开
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => copy(playlistUrl, "已复制 M3U 地址")}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => copy(playlistUrl, "已复制 M3U 地址")}
|
||||
>
|
||||
<CopyIcon />
|
||||
复制 M3U 地址
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 资源来源标识圆点
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SourceType } from "@/types/torbox";
|
||||
|
||||
@@ -9,5 +10,8 @@ const SOURCE_DOT_CLASS: Record<SourceType, string> = {
|
||||
};
|
||||
|
||||
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,3 +1,4 @@
|
||||
// 登录后页面布局
|
||||
import { requireSessionToken } from "@/server/session";
|
||||
|
||||
import { AppHeader } from "./components/app-header";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 资源库首页
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { requireSessionToken } from "@/server/session";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 登录接口
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { SESSION_COOKIE, SESSION_MAX_AGE } from "@/constants/auth";
|
||||
@@ -23,5 +24,8 @@ export async function POST(req: NextRequest) {
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE
|
||||
});
|
||||
return Response.json({ success: true }, { headers: { "Cache-Control": "no-store" } });
|
||||
return Response.json(
|
||||
{ success: true },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 退出登录接口
|
||||
import { cookies } from "next/headers";
|
||||
import { SESSION_COOKIE } from "@/constants/auth";
|
||||
|
||||
@@ -5,5 +6,8 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST() {
|
||||
(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" } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 资源列表接口
|
||||
import type { NextRequest } from "next/server";
|
||||
import { guard, jsonError } from "@/server/auth";
|
||||
import { getLibrary } from "@/server/torbox";
|
||||
@@ -8,11 +9,16 @@ export async function GET(req: NextRequest) {
|
||||
const denied = guard(req);
|
||||
if (denied) return denied;
|
||||
try {
|
||||
// ?fresh=1:用户手动刷新,绕过 TorBox 列表缓存
|
||||
const bypassCache = req.nextUrl.searchParams.get("fresh") === "1";
|
||||
return Response.json(
|
||||
{ success: true, data: await getLibrary() },
|
||||
{ success: true, data: await getLibrary({ bypassCache }) },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} 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 @@
|
||||
// M3U 播放列表接口
|
||||
import type { NextRequest } from "next/server";
|
||||
import { guard, jsonError } from "@/server/auth";
|
||||
import { getLibrary } from "@/server/torbox";
|
||||
@@ -13,16 +14,30 @@ export async function GET(req: NextRequest) {
|
||||
const { origin, searchParams } = new URL(req.url);
|
||||
const token = searchParams.get("token") ?? "";
|
||||
const lines = ["#EXTM3U"];
|
||||
// 只收录可播放的资源,下载中或已失效的文件放进播放列表也无法播放
|
||||
for (const item of items) {
|
||||
if (item.status !== "ready") continue;
|
||||
for (const file of item.files) {
|
||||
const title = `${item.name} / ${file.short_name ?? file.name}`.replace(/[\r\n,]/g, " ");
|
||||
lines.push(`#EXTINF:-1,${title}`, buildStreamUrl(origin, file.playUrl, file.name, token));
|
||||
const title = `${item.name} / ${file.short_name ?? file.name}`.replace(
|
||||
/[\r\n,]/g,
|
||||
" "
|
||||
);
|
||||
lines.push(
|
||||
`#EXTINF:-1,${title}`,
|
||||
buildStreamUrl(origin, file.playUrl, file.name, token)
|
||||
);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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 { guard, jsonError } from "@/server/auth";
|
||||
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
|
||||
@@ -16,7 +17,11 @@ const PASS_HEADERS = [
|
||||
|
||||
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);
|
||||
if (denied) return denied;
|
||||
|
||||
@@ -45,7 +50,10 @@ const proxy = async (req: NextRequest, { params }: Ctx, method: "GET" | "HEAD")
|
||||
headers
|
||||
});
|
||||
} catch (error) {
|
||||
return jsonError(502, error instanceof Error ? error.message : "upstream error");
|
||||
return jsonError(
|
||||
502,
|
||||
error instanceof Error ? error.message : "upstream error"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+35
-3
@@ -1,3 +1,5 @@
|
||||
/* 全局样式与主题变量 */
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@@ -7,7 +9,8 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--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-heading: var(--font-sans);
|
||||
--font-display: var(--font-doto), var(--font-geist-mono), monospace;
|
||||
@@ -87,12 +90,41 @@
|
||||
--sidebar-ring: oklch(0.713 0.15 164.6);
|
||||
}
|
||||
|
||||
/* 点阵背景:呼应 TorBox 点阵字标,仅用于登录页背景与视频卡封面 */
|
||||
/* 点阵背景:呼应 TorBox 点阵字标,仅用于登录页背景与视频卡封面;
|
||||
--dot-color / --dot-size 可覆盖,用于叠一层「点亮」的点阵 */
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 准星四角: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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
+20
-4
@@ -1,15 +1,24 @@
|
||||
// 根布局
|
||||
import "./globals.css";
|
||||
import { Doto, Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/toast";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import MotionProvider from "@/providers/motion-provider";
|
||||
import QueryProvider from "@/providers/query-provider";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
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 字标
|
||||
const doto = Doto({ subsets: ["latin"], weight: "800", variable: "--font-doto" });
|
||||
const doto = Doto({
|
||||
subsets: ["latin"],
|
||||
weight: "800",
|
||||
variable: "--font-doto"
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -26,11 +35,18 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={cn("dark antialiased", geistSans.variable, geistMono.variable, doto.variable)}
|
||||
className={cn(
|
||||
"dark antialiased",
|
||||
geistSans.variable,
|
||||
geistMono.variable,
|
||||
doto.variable
|
||||
)}
|
||||
>
|
||||
<body>
|
||||
<QueryProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<MotionProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</MotionProvider>
|
||||
</QueryProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 登录表单
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useAnimate } from "motion/react";
|
||||
import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react";
|
||||
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
@@ -26,22 +28,39 @@ import {
|
||||
InputGroupInput
|
||||
} from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { SCRAMBLE, SHAKE } from "@/constants/motion";
|
||||
import { useLogin } from "@/hooks/use-auth";
|
||||
|
||||
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 [visible, setVisible] = useState(false);
|
||||
const { mutate, isPending, isSuccess, error, reset } = useLogin();
|
||||
const invalid = isAxiosError(error) && error.response?.status === 401;
|
||||
const [scope, animate] = useAnimate<HTMLDivElement>();
|
||||
const { mutate, isPending, isSuccess, error, reset } = useLogin({
|
||||
beforeNavigate: () => onAccept?.(scope.current)
|
||||
});
|
||||
const invalid = isUnauthorized(error);
|
||||
const busy = isPending || isSuccess;
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-sm">
|
||||
<Card ref={scope} className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<div className="mb-4">
|
||||
<BrandMark />
|
||||
<BrandMark scrambleOnMount={SCRAMBLE.mountDelay} />
|
||||
</div>
|
||||
<CardTitle>输入访问密钥</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -50,7 +69,14 @@ export const LoginForm = () => {
|
||||
id={FORM_ID}
|
||||
onSubmit={(event) => {
|
||||
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>
|
||||
@@ -71,6 +97,7 @@ export const LoginForm = () => {
|
||||
onChange={(event) => {
|
||||
setToken(event.target.value);
|
||||
if (error) reset();
|
||||
onTyping?.(event.currentTarget);
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,16 @@
|
||||
// 登录页
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { LoginForm } from "./components/login-form";
|
||||
import { LoginStage } from "./components/login-stage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "登录"
|
||||
};
|
||||
|
||||
/** 外层只负责铺满视口与静态点阵;交互点阵与表单由 LoginStage 叠放 */
|
||||
const LoginPage = () => (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-dot-grid p-4">
|
||||
<LoginForm />
|
||||
<p className="font-mono text-[0.65rem] tracking-[0.2em] text-muted-foreground">
|
||||
PRIVATE · SELF-HOSTED · POTPLAYER STREAM
|
||||
</p>
|
||||
<main className="grid h-dvh grid-cols-1 grid-rows-1 overflow-hidden bg-dot-grid">
|
||||
<LoginStage />
|
||||
</main>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -1,16 +1,41 @@
|
||||
// 品牌标识
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { BoxIcon } from "lucide-react";
|
||||
|
||||
/** TorBox 品牌字标:翠绿立方体 + 点阵 TORBOX + 等宽 ADMIN */
|
||||
export const BrandMark = () => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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 className="flex items-baseline gap-2">
|
||||
<span className="font-display text-xl leading-none tracking-wider">TORBOX</span>
|
||||
<span className="font-mono text-[0.65rem] leading-none tracking-[0.2em] text-muted-foreground">
|
||||
ADMIN
|
||||
import { ScrambleText, type ScrambleTextHandle } from "@/components/scramble-text";
|
||||
|
||||
type BrandMarkProps = {
|
||||
/** 挂载后自动解码一次的延迟(秒);不传则只在悬停时解码 */
|
||||
scrambleOnMount?: number;
|
||||
};
|
||||
|
||||
/** TorBox 品牌字标:翠绿立方体 + 点阵 TORBOX + 等宽 ADMIN;悬停时字标重新解码 */
|
||||
export const BrandMark = ({ scrambleOnMount }: BrandMarkProps) => {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
<span className="flex items-baseline gap-2">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)} />;
|
||||
};
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// 登录相关常量
|
||||
|
||||
/** 登录态 cookie:保存用户输入的 ACCESS_TOKEN,httpOnly 仅服务端可读 */
|
||||
export const SESSION_COOKIE = "torbox_admin_token";
|
||||
|
||||
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
// TorBox 相关常量
|
||||
import type { SourceType } from "@/types/torbox";
|
||||
|
||||
export const TORBOX_API = "https://api.torbox.app/v1/api";
|
||||
@@ -9,16 +10,88 @@ export const SOURCES = [
|
||||
] as const;
|
||||
|
||||
export const IMAGE_EXT = new Set([
|
||||
"jpg", "jpeg", "png", "gif", "webp", "avif", "apng", "svg", "svgz", "ico",
|
||||
"bmp", "tif", "tiff", "tga", "jxl", "heic", "heif"
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"webp",
|
||||
"avif",
|
||||
"apng",
|
||||
"svg",
|
||||
"svgz",
|
||||
"ico",
|
||||
"bmp",
|
||||
"tif",
|
||||
"tiff",
|
||||
"tga",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif"
|
||||
]);
|
||||
|
||||
export const VIDEO_EXT = new Set([
|
||||
"mp4", "webm", "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"
|
||||
"mp4",
|
||||
"webm",
|
||||
"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> = {
|
||||
torrents: "Torrent",
|
||||
usenet: "Usenet",
|
||||
|
||||
+10
-2
@@ -1,13 +1,21 @@
|
||||
// 登录与退出登录 hook
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { login, logout } from "@/services/auth";
|
||||
|
||||
export const useLogin = () => {
|
||||
type UseLoginOptions = {
|
||||
/** 登录成功、跳转前执行(如播放过场动效),返回 Promise 时等待其完成 */
|
||||
beforeNavigate?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const useLogin = ({ beforeNavigate }: UseLoginOptions = {}) => {
|
||||
const router = useRouter();
|
||||
return useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
router.prefetch("/");
|
||||
await beforeNavigate?.();
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 复制文本并提示的 hook
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { message } from "@/lib/message";
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// 监听元素内容宽度的 hook
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const getContentWidth = (node: HTMLElement) => {
|
||||
const style = getComputedStyle(node);
|
||||
return node.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
return (
|
||||
node.clientWidth -
|
||||
parseFloat(style.paddingLeft) -
|
||||
parseFloat(style.paddingRight)
|
||||
);
|
||||
};
|
||||
|
||||
/** 元素内容区宽度(不含内边距),挂载时同步测量,之后随 ResizeObserver 更新 */
|
||||
@@ -11,7 +16,9 @@ export const useElementWidth = <T extends HTMLElement>() => {
|
||||
|
||||
const ref = useCallback((node: T | null) => {
|
||||
if (!node) return;
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
|
||||
const observer = new ResizeObserver(([entry]) =>
|
||||
setWidth(entry.contentRect.width)
|
||||
);
|
||||
observer.observe(node);
|
||||
setWidth(getContentWidth(node));
|
||||
return () => observer.disconnect();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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
@@ -1,10 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
// 资源库数据查询与筛选 hook
|
||||
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 { 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 { 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 & {
|
||||
/** 列表展示名(short_name 优先) */
|
||||
@@ -16,15 +27,41 @@ export type LibraryFileView = LibraryFile & {
|
||||
export type LibraryItemView = Omit<LibraryItem, "files"> & {
|
||||
files: LibraryFileView[];
|
||||
totalSize: number;
|
||||
/** 在本次拉取前 NEW_ITEM_WINDOW_MS 内加入 */
|
||||
isNew: boolean;
|
||||
/** 距过期的剩余毫秒数(相对本次拉取时间,已过期为负数);无过期时间时为 null */
|
||||
expiresInMs: number | null;
|
||||
};
|
||||
|
||||
const LIBRARY_QUERY_KEY = ["library"] as const;
|
||||
|
||||
export const useLibrary = () =>
|
||||
useQuery({
|
||||
queryKey: ["library"],
|
||||
queryFn: getLibrary,
|
||||
queryKey: LIBRARY_QUERY_KEY,
|
||||
queryFn: () => getLibrary(),
|
||||
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[]) =>
|
||||
files.reduce((total, file) => total + (file.size ?? 0), 0);
|
||||
|
||||
@@ -48,12 +85,30 @@ export const useFilteredLibrary = () => {
|
||||
const files = item.files
|
||||
.map((file) => {
|
||||
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);
|
||||
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(() => {
|
||||
@@ -79,7 +134,10 @@ export const useFilteredLibrary = () => {
|
||||
};
|
||||
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 {
|
||||
items: visible,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 获取当前站点 origin 的 hook
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = () => () => {};
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// 服务端启动钩子(环境变量校验)
|
||||
export async function register() {
|
||||
// 仅在 Node.js 服务端运行时校验,启动失败即暴露配置问题
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Axios 请求实例与拦截器
|
||||
import axios from "axios";
|
||||
import { LOGIN_API } from "@/constants/auth";
|
||||
import { message } from "./message";
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// 业务消息提示入口
|
||||
import { toast } from "@/components/ui/toast";
|
||||
|
||||
type MessageOptions = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// TanStack Query 客户端工厂
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function makeQueryClient() {
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// TanStack Query Provider
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
+5
-2
@@ -1,3 +1,4 @@
|
||||
// 页面访问鉴权拦截
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { SESSION_COOKIE } from "@/constants/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 isLoginPage = req.nextUrl.pathname === LOGIN_PATH;
|
||||
|
||||
if (!authed && !isLoginPage) return NextResponse.redirect(new URL(LOGIN_PATH, req.url));
|
||||
if (authed && isLoginPage) return NextResponse.redirect(new URL("/", req.url));
|
||||
if (!authed && !isLoginPage)
|
||||
return NextResponse.redirect(new URL(LOGIN_PATH, req.url));
|
||||
if (authed && isLoginPage)
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -1,3 +1,4 @@
|
||||
// 服务端鉴权工具
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { SESSION_COOKIE } from "@/constants/auth";
|
||||
@@ -10,7 +11,9 @@ const safeEqual = (a: string, b: string) => {
|
||||
};
|
||||
|
||||
/** 校验传入值是否等于环境变量 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"));
|
||||
|
||||
/** 校验登录 cookie、Bearer 头或 ?token=(供 PotPlayer 等外部播放器使用)任一有效 */
|
||||
@@ -25,7 +28,10 @@ export const isAuthorized = (req: NextRequest) => {
|
||||
};
|
||||
|
||||
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 表示通过 */
|
||||
export const guard = (req: NextRequest) => {
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
// 服务端环境变量校验
|
||||
const REQUIRED_ENV = ["ACCESS_TOKEN", "TORBOX_API_KEY"] as const;
|
||||
|
||||
type RequiredEnv = (typeof REQUIRED_ENV)[number];
|
||||
@@ -6,7 +7,9 @@ type RequiredEnv = (typeof REQUIRED_ENV)[number];
|
||||
export const assertServerEnv = () => {
|
||||
const missing = REQUIRED_ENV.filter((name) => !process.env[name]?.trim());
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`缺少必要的环境变量:${missing.join("、")}。请在 .env.local 或容器环境中配置后重启`);
|
||||
throw new Error(
|
||||
`缺少必要的环境变量:${missing.join("、")}。请在 .env.local 或容器环境中配置后重启`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 服务端登录态读取
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SESSION_COOKIE } from "@/constants/auth";
|
||||
|
||||
+122
-19
@@ -1,9 +1,50 @@
|
||||
// TorBox API 服务端封装
|
||||
import { serverEnv } from "@/server/env";
|
||||
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 RawItem = { id: number; name: string; files?: RawFile[] };
|
||||
type 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");
|
||||
|
||||
@@ -25,27 +66,89 @@ const torboxFetch = async (path: string) => {
|
||||
export const isSourceType = (value: string): value is SourceType =>
|
||||
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 items: LibraryItem[] = [];
|
||||
|
||||
// 三个来源并行,各自内部按页顺序拉取
|
||||
await Promise.all(
|
||||
SOURCES.map(async ({ type, list }) => {
|
||||
try {
|
||||
const data: RawItem[] = (await torboxFetch(`${list}?limit=1000`)) ?? [];
|
||||
for (const item of data) {
|
||||
items.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
source: type,
|
||||
files: (item.files ?? []).map((file) => ({
|
||||
...file,
|
||||
playUrl: `/api/stream/${type}/${item.id}/${file.id}`
|
||||
}))
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errors[type] = error instanceof Error ? error.message : "unknown";
|
||||
const { items: data, error } = await fetchAllPages(list, bypassCache);
|
||||
if (error) errors[type] = error;
|
||||
for (const item of data) {
|
||||
items.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
source: type,
|
||||
createdAt: item.created_at ?? null,
|
||||
expiresAt: item.expires_at ?? null,
|
||||
status: getDownloadStatus(item),
|
||||
progress: toProgressRatio(item.progress),
|
||||
downloadState: item.download_state ?? null,
|
||||
// 只下发前端需要的字段,s3_path、absolute_path 等内部信息不出服务端
|
||||
files: (item.files ?? []).map((file) => ({
|
||||
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,3 +1,4 @@
|
||||
// 认证相关 API 请求
|
||||
import { LOGIN_API } from "@/constants/auth";
|
||||
import BaseClientService from "@/lib/axios";
|
||||
|
||||
|
||||
+11
-4
@@ -1,9 +1,16 @@
|
||||
// 资源库 API 请求
|
||||
import BaseClientService from "@/lib/axios";
|
||||
import type { LibraryResponse } from "@/types/torbox";
|
||||
|
||||
export const getLibrary = async () => {
|
||||
const { data } = await BaseClientService.get<{ success: boolean; data: LibraryResponse }>(
|
||||
"/library"
|
||||
);
|
||||
type GetLibraryOptions = {
|
||||
/** 绕过 TorBox 列表缓存拿最新数据,仅用于用户手动刷新 */
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 资源列表筛选状态 store
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { SourceType } from "@/types/torbox";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 资源列表视图状态 store(滚动位置等)
|
||||
import type { VirtualItem } from "@tanstack/react-virtual";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// TorBox 业务类型
|
||||
export type SourceType = "torrents" | "usenet" | "webdl";
|
||||
|
||||
export type LibraryFile = {
|
||||
@@ -5,14 +6,33 @@ export type LibraryFile = {
|
||||
name: string;
|
||||
short_name?: string;
|
||||
size?: number;
|
||||
/** TorBox 识别的 MIME 类型,如 video/x-matroska;缺失时为 null */
|
||||
mimetype: string | null;
|
||||
/** 服务端生成的相对播放路径,不含 token */
|
||||
playUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源是否可播放:
|
||||
* - ready:已下载完成且文件仍在 TorBox 服务器上
|
||||
* - downloading:尚未下载完成
|
||||
* - unavailable:已完成但文件已不在服务器上(过期或被清理)
|
||||
*/
|
||||
export type DownloadStatus = "ready" | "downloading" | "unavailable";
|
||||
|
||||
export type LibraryItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// 剪贴板复制函数
|
||||
|
||||
/** 兼容非安全上下文(局域网 http 部署时 navigator.clipboard 不可用)的复制 */
|
||||
export const copyText = async (text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// 格式化函数
|
||||
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
/** 以 1024 进制格式化字节数,如 4.3 GB;未知大小返回 "—" */
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
// 媒体文件类型与路径处理函数
|
||||
import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox";
|
||||
|
||||
export type MediaKind = "video" | "image" | "other";
|
||||
@@ -12,7 +13,15 @@ export const getFileExt = (name: string) => {
|
||||
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);
|
||||
if (VIDEO_EXT.has(ext)) return "video";
|
||||
if (IMAGE_EXT.has(ext)) return "image";
|
||||
@@ -28,7 +37,8 @@ export const buildStreamUrl = (
|
||||
) => {
|
||||
const url = new URL(playUrl, origin);
|
||||
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);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user