feat: 完善页面内容

This commit is contained in:
2026-10-05 06:50:57 -07:00
parent 10a1983de0
commit 87f3756ff0
66 changed files with 2755 additions and 296 deletions
+3 -1
View File
@@ -46,4 +46,6 @@ next-env.d.ts
/.hermes/
AGENTS.md
CLAUDE.md
CLAUDE.md
.vscode
+1 -1
View File
@@ -33,6 +33,6 @@ USER node
EXPOSE 22786
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://127.0.0.1:22786/ || exit 1
CMD wget -q --spider http://127.0.0.1:22786/login || exit 1
CMD ["node", "server.js"]
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@base-ui/react": "^1.8.0",
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-virtual": "^3.14.13",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.3.2",
+20
View File
@@ -14,6 +14,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.103.2
version: 5.103.2(react@19.2.8)
'@tanstack/react-virtual':
specifier: ^3.14.13
version: 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
axios:
specifier: ^1.20.0
version: 1.20.0
@@ -834,6 +837,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.14.13':
resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.11':
resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==}
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -3933,6 +3945,14 @@ snapshots:
'@tanstack/query-core': 5.103.2
react: 19.2.8
'@tanstack/react-virtual@3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@tanstack/virtual-core': 3.17.11
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
'@tanstack/virtual-core@3.17.11': {}
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
+18
View File
@@ -0,0 +1,18 @@
import { BrandMark } from "@/components/brand-mark";
import { Separator } from "@/components/ui/separator";
import { LogoutButton } from "./logout-button";
import { PlaylistMenu } from "./playlist-menu";
export const AppHeader = ({ token }: { token: string }) => (
<header className="shrink-0 border-b">
<div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 md:px-6">
<BrandMark />
<div className="ml-auto flex items-center gap-1">
<PlaylistMenu token={token} />
<Separator orientation="vertical" className="mx-1 my-4" />
<LogoutButton />
</div>
</div>
</header>
);
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { CopyIcon, DownloadIcon, EyeIcon, FileIcon, ImageOffIcon, PlayIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useCopy } from "@/hooks/use-copy";
import type { LibraryFileView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
import { formatBytes } from "@/utils/format";
import { buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
const ExtLabel = ({ ext }: { ext: string }) => (
<span className="font-mono uppercase">{ext || "file"}</span>
);
/** 无法预览时的占位封面 */
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 origin = useOrigin();
const copy = useCopy();
const [previewOpen, setPreviewOpen] = useState(false);
const [imageFailed, setImageFailed] = useState(false);
if (!origin) return null;
const streamUrl = buildStreamUrl(origin, file.playUrl, file.displayName, token);
const potPlayerUrl = toPotPlayerUrl(streamUrl);
let cover: React.ReactNode;
let primaryAction: React.ReactNode;
if (file.kind === "video") {
cover = (
<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>
);
primaryAction = (
<Button size="sm" className="flex-1" render={<a href={potPlayerUrl} />} nativeButton={false}>
<PlayIcon data-icon="inline-start" />
PotPlayer
</Button>
);
} else if (file.kind === "image") {
cover = imageFailed ? (
<PlaceholderCover ext={file.ext} icon={ImageOffIcon} />
) : (
<button
type="button"
title="预览图片"
className="flex aspect-video overflow-hidden bg-muted"
onClick={() => setPreviewOpen(true)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={streamUrl}
alt={file.displayName}
loading="lazy"
className="size-full object-cover transition-transform hover:scale-105"
onError={() => setImageFailed(true)}
/>
</button>
);
primaryAction = (
<Button size="sm" variant="secondary" className="flex-1" onClick={() => setPreviewOpen(true)}>
<EyeIcon data-icon="inline-start" />
预览
</Button>
);
} else {
cover = <PlaceholderCover ext={file.ext} icon={FileIcon} />;
primaryAction = (
<Button
size="sm"
variant="secondary"
className="flex-1"
render={<a href={streamUrl} download={file.displayName} />}
nativeButton={false}
>
<DownloadIcon data-icon="inline-start" />
下载
</Button>
);
}
return (
<Card size="sm" className="gap-0 py-0 transition-shadow hover:ring-primary/40">
{cover}
<CardContent className="flex flex-1 flex-col gap-1 py-3">
<p className="line-clamp-2 leading-snug break-all" title={file.name}>
{file.displayName}
</p>
<p className="font-mono text-xs text-muted-foreground tabular-nums">
<ExtLabel ext={file.ext} /> · {formatBytes(file.size)}
</p>
</CardContent>
<CardFooter className="gap-2">
{primaryAction}
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="复制直链"
onClick={() => copy(streamUrl, "已复制直链")}
/>
}
>
<CopyIcon />
</TooltipTrigger>
<TooltipContent>复制直链</TooltipContent>
</Tooltip>
</CardFooter>
{file.kind === "image" && (
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle className="truncate pr-8">{file.displayName}</DialogTitle>
</DialogHeader>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={streamUrl}
alt={file.displayName}
className="max-h-[75dvh] w-full rounded-lg bg-muted object-contain"
/>
</DialogContent>
</Dialog>
)}
</Card>
);
};
+23
View File
@@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
const COLUMN_CLASS: Record<number, string> = {
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5"
};
/**
* 等宽卡片网格:规范中唯一允许的 grid 布局,其余一律 flex;最多 5 列。
* 传入 columns 时使用固定列数(虚拟列表按容器宽度计算),否则按视口断点 2→3→4→5 列。
*/
export const FileGrid = ({ columns, children }: { columns?: number; children: React.ReactNode }) => (
<div
className={cn(
"grid gap-3",
columns ? COLUMN_CLASS[columns] : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
)}
>
{children}
</div>
);
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { ChevronRightIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SOURCE_LABELS } from "@/constants/torbox";
import type { LibraryItemView } from "@/hooks/use-library";
import { formatBytes, formatCount } from "@/utils/format";
import { SourceDot } from "./source-dot";
type ItemHeaderProps = {
item: LibraryItemView;
expanded: boolean;
onToggle: () => void;
};
/** 资源分组标题行:虚拟列表中标题与卡片行相互独立,折叠状态由外部控制 */
export const ItemHeader = ({ item, expanded, onToggle }: ItemHeaderProps) => (
<div className="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
aria-label={expanded ? "收起" : "展开"}
aria-expanded={expanded}
data-open={expanded || undefined}
className="data-open:[&_svg]:rotate-90"
onClick={onToggle}
>
<ChevronRightIcon className="transition-transform" />
</Button>
<Badge variant="outline">
<SourceDot source={item.source} />
{SOURCE_LABELS[item.source]}
</Badge>
<h2 className="min-w-0 flex-1 truncate font-medium" title={item.name}>
{item.name}
</h2>
<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>
</span>
</div>
);
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
import { TriangleAlertIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { SOURCE_LABELS } from "@/constants/torbox";
import { useElementWidth } from "@/hooks/use-element-width";
import type { LibraryItemView } from "@/hooks/use-library";
import { cn } from "@/lib/utils";
import { useLibraryFilterStore } from "@/stores/library-filter-store";
import { useLibraryViewStore, type LibraryScrollSnapshot } from "@/stores/library-view-store";
import type { SourceType } from "@/types/torbox";
import { FileCard } from "./file-card";
import { FileGrid } from "./file-grid";
import { ItemHeader } from "./item-header";
import {
GRID_GAP,
buildLibraryRows,
estimateRowSize,
getColumnCount,
type LibraryRow
} from "./library-rows";
const PADDING_START = 20;
const PADDING_END = 32;
/** 卡片行较高,上下各多渲染 3 行即可覆盖快速滚动 */
const OVERSCAN = 3;
type RowVirtualizer = Virtualizer<HTMLDivElement, HTMLDivElement>;
type LibraryListProps = {
/** 产品 UI 持有的滚动容器(header 下方的内容区) */
scrollRef: React.RefObject<HTMLDivElement | null>;
items: LibraryItemView[];
notices: [SourceType, string][];
token: string;
};
/** 记录首个可见行与偏移,供刷新页面后按锚点恢复 */
const takeScrollSnapshot = (
instance: RowVirtualizer,
signature: string
): LibraryScrollSnapshot | null => {
const offset = instance.scrollOffset ?? 0;
const anchor = instance.getVirtualItemForOffset(offset);
if (!anchor) return null;
return {
signature,
anchorKey: String(anchor.key),
anchorDelta: offset - anchor.start,
measurements: instance.takeSnapshot()
};
};
/** 按锚点行在当前数据中的位置换算恢复偏移;锚点已不存在则回到顶部 */
const getRestoreOffset = (
rows: LibraryRow[],
snapshot: LibraryScrollSnapshot,
estimate: (index: number) => number
) => {
const sizes = new Map(snapshot.measurements.map((item) => [String(item.key), item.size]));
let offset = PADDING_START;
for (let index = 0; index < rows.length; index += 1) {
if (rows[index].key === snapshot.anchorKey) return Math.max(0, offset + snapshot.anchorDelta);
offset += sizes.get(rows[index].key) ?? estimate(index);
}
return 0;
};
/** 行自身的留白计入测量高度,因此虚拟器 gap 为 0 */
const rowClassName = (row: LibraryRow) => {
if (row.type === "notice") return "pb-2";
if (row.type === "header") return cn("pb-3", !row.first && "pt-5");
return "pb-3";
};
export const LibraryList = (props: LibraryListProps) => {
const [contentRef, width] = useElementWidth<HTMLDivElement>();
return (
<div ref={contentRef} className="mx-auto w-full max-w-7xl px-4 md:px-6">
{/* 先拿到容器宽度再创建虚拟器,保证首帧列数与快照恢复一致 */}
{width > 0 && <VirtualRows {...props} width={width} />}
</div>
);
};
const VirtualRows = ({ scrollRef, items, notices, token, width }: LibraryListProps & { width: number }) => {
const columns = getColumnCount(width);
const cardWidth = (width - GRID_GAP * (columns - 1)) / columns;
const collapsed = useLibraryViewStore((state) => state.collapsed);
const toggleCollapsed = useLibraryViewStore((state) => state.toggleCollapsed);
const saveSnapshot = useLibraryViewStore((state) => state.saveSnapshot);
const signature = useLibraryFilterStore((state) =>
JSON.stringify([state.keyword.trim(), state.source, state.kind])
);
const rows = useMemo(
() => buildLibraryRows(items, notices, columns, new Set(collapsed)),
[items, notices, columns, collapsed]
);
const getItemKey = useCallback((index: number) => rows[index].key, [rows]);
const estimateSize = useCallback(
(index: number) => estimateRowSize(rows[index], cardWidth),
[rows, cardWidth]
);
// 只在挂载时读取一次快照,筛选条件一致才恢复
const [restore] = useState(() => {
const snapshot = useLibraryViewStore.getState().snapshot;
return snapshot?.signature === signature ? snapshot : null;
});
// 停在顶部时不锚定,刷新出的新资源直接出现在顶部;向下浏览时锚定当前行,避免内容跳动
const [atTop, setAtTop] = useState(restore === null);
const wasScrolling = useRef(false);
// eslint-disable-next-line react-hooks/incompatible-library -- 项目未启用 React Compiler;虚拟器每次渲染都需读取最新滚动状态
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rows.length,
getScrollElement: () => scrollRef.current,
getItemKey,
estimateSize,
overscan: OVERSCAN,
paddingStart: PADDING_START,
paddingEnd: PADDING_END,
anchorTo: atTop ? "start" : "end",
initialMeasurementsCache: restore?.measurements,
initialOffset: () => (restore ? getRestoreOffset(rows, restore, estimateSize) : 0),
onChange: (instance) => {
setAtTop((instance.scrollOffset ?? 0) <= 1);
// 每次滚动停止时保存快照
if (wasScrolling.current && !instance.isScrolling) {
const snapshot = takeScrollSnapshot(instance, signature);
if (snapshot) saveSnapshot(snapshot);
}
wasScrolling.current = instance.isScrolling;
}
});
// 筛选条件变化时回到顶部(首次挂载不处理,以免覆盖快照恢复)
const lastSignature = useRef(signature);
useLayoutEffect(() => {
if (lastSignature.current === signature) return;
lastSignature.current = signature;
virtualizer.scrollToOffset(0);
}, [signature, virtualizer]);
// 卸载时(如列表被空状态替换)补存一次快照
useEffect(
() => () => {
const snapshot = takeScrollSnapshot(virtualizer, lastSignature.current);
if (snapshot) saveSnapshot(snapshot);
},
[virtualizer, saveSnapshot]
);
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const first = virtualRows[0];
const last = virtualRows[virtualRows.length - 1];
return (
<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 }} />
</div>
);
};
+77
View File
@@ -0,0 +1,77 @@
import type { LibraryFileView, LibraryItemView } from "@/hooks/use-library";
import type { SourceType } from "@/types/torbox";
/** 卡片网格参数:与 FileGrid 的 gap-3 保持一致 */
export const GRID_GAP = 12;
const MIN_CARD_WIDTH = 190;
const MIN_COLUMNS = 2;
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)))
);
/** 虚拟列表的一行:来源错误提示、资源标题,或一整行文件卡片 */
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: "empty"; key: string };
export const getItemKey = (item: LibraryItemView) => `${item.source}-${item.id}`;
/**
* 把分组数据拍平成行。行 key 只依赖资源与分块序号、不依赖列数,
* 保证刷新、插入新资源、窗口缩放时锚点行仍可定位。
*/
export const buildLibraryRows = (
items: LibraryItemView[],
notices: [SourceType, string][],
columns: number,
collapsed: ReadonlySet<string>
) => {
const rows: LibraryRow[] = notices.map(([source, detail]) => ({
type: "notice",
key: `notice:${source}`,
source,
detail
}));
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 });
if (!expanded) continue;
if (item.files.length === 0) {
rows.push({ type: "empty", key: `empty:${itemKey}` });
continue;
}
for (let start = 0; start < item.files.length; start += columns) {
rows.push({
type: "cards",
key: `cards:${itemKey}:${start / columns}`,
files: item.files.slice(start, start + columns)
});
}
}
return rows;
};
/** 未测量行的预估高度(含行自身的上下留白),越接近真实值滚动条越稳定 */
export const estimateRowSize = (row: LibraryRow, cardWidth: number) => {
switch (row.type) {
case "notice":
return 72;
case "header":
return row.first ? 40 : 60;
case "cards":
// 16:9 封面 + 文件名/元信息 + 操作栏 + 行间距
return Math.round((cardWidth * 9) / 16) + 140;
case "empty":
return 32;
}
};
@@ -0,0 +1,22 @@
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="正在加载资源">
{Array.from({ length: 2 }, (_, section) => (
<div key={section} className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Skeleton className="size-7" />
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-64 max-w-[40%]" />
</div>
<FileGrid>
{Array.from({ length: 6 }, (_, card) => (
<Skeleton key={card} className="h-56" />
))}
</FileGrid>
</div>
))}
</div>
);
@@ -0,0 +1,135 @@
"use client";
import { RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SOURCES, SOURCE_LABELS } from "@/constants/torbox";
import {
useLibraryFilterStore,
type KindFilter,
type SourceFilter
} from "@/stores/library-filter-store";
import { formatBytes, formatCount } from "@/utils/format";
import { SourceDot } from "./source-dot";
const KIND_OPTIONS: { value: KindFilter; label: string }[] = [
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
{ value: "other", label: "其他" }
];
type LibraryToolbarProps = {
sourceCounts: Record<SourceFilter, number>;
summary: { items: number; files: number; size: number };
isFetching: boolean;
onRefresh: () => void;
};
export const LibraryToolbar = ({
sourceCounts,
summary,
isFetching,
onRefresh
}: LibraryToolbarProps) => {
const keyword = useLibraryFilterStore((state) => state.keyword);
const source = useLibraryFilterStore((state) => state.source);
const kind = useLibraryFilterStore((state) => state.kind);
const setKeyword = useLibraryFilterStore((state) => state.setKeyword);
const setSource = useLibraryFilterStore((state) => state.setSource);
const setKind = useLibraryFilterStore((state) => state.setKind);
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">
<InputGroup className="w-full sm:w-64">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
placeholder="搜索资源或文件名"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
{keyword && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="清除搜索" onClick={() => setKeyword("")}>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
{/* 单选:再次点击已选项时保持当前值,不允许清空 */}
<ToggleGroup
aria-label="按来源筛选"
variant="outline"
size="sm"
spacing={0}
value={[source]}
onValueChange={(value) => value[0] && setSource(value[0] as SourceFilter)}
>
<ToggleGroupItem value="all">
全部
<span className="font-mono text-muted-foreground tabular-nums">{sourceCounts.all}</span>
</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>
</ToggleGroupItem>
))}
</ToggleGroup>
<ToggleGroup
aria-label="按文件类型筛选"
variant="outline"
size="sm"
spacing={0}
value={[kind]}
onValueChange={(value) => value[0] && setKind(value[0] as KindFilter)}
>
{KIND_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<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)}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="刷新"
disabled={isFetching}
onClick={onRefresh}
/>
}
>
{isFetching ? <Spinner /> : <RefreshCwIcon />}
</TooltipTrigger>
<TooltipContent side="bottom">从 TorBox 重新拉取</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
};
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useMemo, useRef } from "react";
import { FolderOpenIcon, SearchXIcon, TriangleAlertIcon } from "lucide-react";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty";
import { SOURCE_LABELS } from "@/constants/torbox";
import { useFilteredLibrary } from "@/hooks/use-library";
import { useLibraryFilterStore } from "@/stores/library-filter-store";
import type { SourceType } from "@/types/torbox";
import { LibraryList } from "./library-list";
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 resetFilter = useLibraryFilterStore((state) => state.reset);
// 滚动容器由页面持有,虚拟列表只负责计算渲染哪些行
const scrollRef = useRef<HTMLDivElement>(null);
const notices = useMemo(
() => Object.entries(query.data?.errors ?? {}) as [SourceType, string][],
[query.data]
);
let content: React.ReactNode;
if (query.isPending) {
content = <LibrarySkeleton />;
} else if (query.isError) {
content = (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>资源加载失败</AlertTitle>
<AlertDescription>请检查服务端 TORBOX_API_KEY 配置或网络连接</AlertDescription>
<AlertAction>
<Button size="sm" variant="outline" onClick={() => query.refetch()}>
重试
</Button>
</AlertAction>
</Alert>
);
} else if (items.length === 0) {
content = (
<>
{notices.length > 0 && (
<div className="flex flex-col gap-2">
{notices.map(([source, detail]) => (
<Alert key={source} variant="destructive">
<TriangleAlertIcon />
<AlertTitle>{SOURCE_LABELS[source]} 拉取失败</AlertTitle>
<AlertDescription>{detail}</AlertDescription>
</Alert>
))}
</div>
)}
{isEmptyLibrary ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderOpenIcon />
</EmptyMedia>
<EmptyTitle>账号里还没有资源</EmptyTitle>
<EmptyDescription>在 TorBox 添加下载任务后,点击刷新即可在这里播放</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon />
</EmptyMedia>
<EmptyTitle>没有匹配的文件</EmptyTitle>
<EmptyDescription>换个关键词,或调整来源与类型筛选</EmptyDescription>
</EmptyHeader>
{isFiltered && (
<EmptyContent>
<Button variant="outline" size="sm" onClick={resetFilter}>
清除筛选
</Button>
</EmptyContent>
)}
</Empty>
)}
</>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<LibraryToolbar
sourceCounts={sourceCounts}
summary={summary}
isFetching={query.isFetching}
onRefresh={() => query.refetch()}
/>
<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>
) : (
<LibraryList scrollRef={scrollRef} items={items} notices={notices} token={token} />
)}
</div>
</div>
);
};
@@ -0,0 +1,31 @@
"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 { useLogout } from "@/hooks/use-auth";
export const LogoutButton = () => {
const { mutate, isPending } = useLogout();
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label="退出登录"
disabled={isPending}
onClick={() => mutate()}
/>
}
>
{isPending ? <Spinner /> : <LogOutIcon />}
</TooltipTrigger>
<TooltipContent side="bottom">退出登录</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,45 @@
"use client";
import { ChevronDownIcon, CopyIcon, ListVideoIcon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { useCopy } from "@/hooks/use-copy";
import { useOrigin } from "@/hooks/use-origin";
import { buildPlaylistUrl, toPotPlayerUrl } from "@/utils/media";
export const PlaylistMenu = ({ token }: { token: string }) => {
const origin = useOrigin();
const copy = useCopy();
const playlistUrl = origin ? buildPlaylistUrl(origin, token) : "";
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" disabled={!playlistUrl} />}>
<ListVideoIcon data-icon="inline-start" />
播放列表
<ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>全部资源 · M3U</DropdownMenuLabel>
<DropdownMenuItem onClick={() => window.location.assign(toPotPlayerUrl(playlistUrl))}>
<PlayIcon />
用 PotPlayer 打开
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copy(playlistUrl, "已复制 M3U 地址")}>
<CopyIcon />
复制 M3U 地址
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
import type { SourceType } from "@/types/torbox";
// 来源分类色取自 chart 语义色,规范见 docs/design-system.md
const SOURCE_DOT_CLASS: Record<SourceType, string> = {
torrents: "bg-chart-1",
usenet: "bg-chart-2",
webdl: "bg-chart-3"
};
export const SourceDot = ({ source }: { source: SourceType }) => (
<span aria-hidden="true" className={cn("size-1.5 shrink-0 rounded-full", SOURCE_DOT_CLASS[source])} />
);
+17
View File
@@ -0,0 +1,17 @@
import { requireSessionToken } from "@/server/session";
import { AppHeader } from "./components/app-header";
/** 登录后页面骨架:纵向 flex,header 不参与滚动,滚动发生在各页面内部的内容区 */
const AppLayout = async ({ children }: { children: React.ReactNode }) => {
const token = await requireSessionToken();
return (
<div className="flex h-dvh flex-col">
<AppHeader token={token} />
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
</div>
);
};
export default AppLayout;
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { requireSessionToken } from "@/server/session";
import { LibraryView } from "./components/library-view";
export const metadata: Metadata = {
title: "资源库"
};
const HomePage = async () => {
const token = await requireSessionToken();
return <LibraryView token={token} />;
};
export default HomePage;
+27
View File
@@ -0,0 +1,27 @@
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE, SESSION_MAX_AGE } from "@/constants/auth";
import { jsonError, verifyAccessToken } from "@/server/auth";
export const dynamic = "force-dynamic";
/** 仅在 HTTPS(含反向代理转发)下设置 Secure,避免局域网 http 部署时 cookie 被浏览器丢弃 */
const isHttps = (req: NextRequest) => {
const proto = req.headers.get("x-forwarded-proto") ?? req.nextUrl.protocol;
return proto.split(",")[0].trim().replace(":", "") === "https";
};
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const token = typeof body.token === "string" ? body.token.trim() : "";
if (!verifyAccessToken(token)) return jsonError(401, "访问密钥不正确");
(await cookies()).set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: isHttps(req),
path: "/",
maxAge: SESSION_MAX_AGE
});
return Response.json({ success: true }, { headers: { "Cache-Control": "no-store" } });
}
+9
View File
@@ -0,0 +1,9 @@
import { cookies } from "next/headers";
import { SESSION_COOKIE } from "@/constants/auth";
export const dynamic = "force-dynamic";
export async function POST() {
(await cookies()).delete(SESSION_COOKIE);
return Response.json({ success: true }, { headers: { "Cache-Control": "no-store" } });
}
+2 -1
View File
@@ -1,9 +1,10 @@
import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth";
import { getLibrary } from "@/server/torbox";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
export async function GET(req: NextRequest) {
const denied = guard(req);
if (denied) return denied;
try {
+2 -1
View File
@@ -1,10 +1,11 @@
import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth";
import { getLibrary } from "@/server/torbox";
import { buildStreamUrl } from "@/utils/media";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
export async function GET(req: NextRequest) {
const denied = guard(req);
if (denied) return denied;
try {
@@ -1,3 +1,4 @@
import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth";
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
@@ -15,7 +16,7 @@ const PASS_HEADERS = [
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
const proxy = async (req: Request, { params }: Ctx, method: "GET" | "HEAD") => {
const proxy = async (req: NextRequest, { params }: Ctx, method: "GET" | "HEAD") => {
const denied = guard(req);
if (denied) return denied;
@@ -48,5 +49,5 @@ const proxy = async (req: Request, { params }: Ctx, method: "GET" | "HEAD") => {
}
};
export const GET = (req: Request, ctx: Ctx) => proxy(req, ctx, "GET");
export const HEAD = (req: Request, ctx: Ctx) => proxy(req, ctx, "HEAD");
export const GET = (req: NextRequest, ctx: Ctx) => proxy(req, ctx, "GET");
export const HEAD = (req: NextRequest, ctx: Ctx) => proxy(req, ctx, "HEAD");
+43 -67
View File
@@ -7,9 +7,10 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--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;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -48,73 +49,48 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* 仅深色主题::root 与 .dark 使用同一套 TorBox 品牌色,规范见 docs/design-system.md */
:root,
.dark {
color-scheme: dark;
--background: oklch(0.192 0.015 272.5);
--foreground: oklch(0.97 0.005 265);
--card: oklch(0.223 0.018 270.5);
--card-foreground: oklch(0.97 0.005 265);
--popover: oklch(0.223 0.018 270.5);
--popover-foreground: oklch(0.97 0.005 265);
--primary: oklch(0.713 0.15 164.6);
--primary-foreground: oklch(0.159 0.011 268);
--secondary: oklch(0.257 0.02 268);
--secondary-foreground: oklch(0.97 0.005 265);
--muted: oklch(0.257 0.02 268);
--muted-foreground: oklch(0.714 0.019 261);
--accent: oklch(0.257 0.02 268);
--accent-foreground: oklch(0.97 0.005 265);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.713 0.15 164.6);
--chart-1: oklch(0.713 0.15 164.6);
--chart-2: oklch(0.7 0.13 235);
--chart-3: oklch(0.8 0.14 80);
--chart-4: oklch(0.68 0.16 295);
--chart-5: oklch(0.7 0.17 10);
--radius: 0.375rem;
--sidebar: oklch(0.223 0.018 270.5);
--sidebar-foreground: oklch(0.97 0.005 265);
--sidebar-primary: oklch(0.713 0.15 164.6);
--sidebar-primary-foreground: oklch(0.159 0.011 268);
--sidebar-accent: oklch(0.257 0.02 268);
--sidebar-accent-foreground: oklch(0.97 0.005 265);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.713 0.15 164.6);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* 点阵背景:呼应 TorBox 点阵字标,仅用于登录页背景与视频卡封面 */
@utility bg-dot-grid {
background-image: radial-gradient(var(--border) 1px, transparent 1px);
background-size: 14px 14px;
}
@layer base {
+15 -2
View File
@@ -1,8 +1,16 @@
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 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" });
// 点阵字体仅用于 TORBOX 字标
const doto = Doto({ subsets: ["latin"], weight: "800", variable: "--font-doto" });
export const metadata: Metadata = {
title: {
default: "TorBox Admin",
@@ -16,9 +24,14 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<html
lang="zh-CN"
className={cn("dark antialiased", geistSans.variable, geistMono.variable, doto.variable)}
>
<body>
<QueryProvider>{children}</QueryProvider>
<QueryProvider>
<TooltipProvider>{children}</TooltipProvider>
</QueryProvider>
<Toaster />
</body>
</html>
-48
View File
@@ -1,48 +0,0 @@
"use client";
import type { LibraryFile } from "@/types/torbox";
import { buildStreamUrl, getMediaKind, toPotPlayerUrl } from "@/utils/media";
export const FileCard = ({ file, token }: { file: LibraryFile; token: string }) => {
const name = file.short_name ?? file.name;
const kind = getMediaKind(name);
if (kind === "other") {
return (
<div className="rounded-lg border border-dashed p-3 text-sm break-all text-muted-foreground">
{name}
</div>
);
}
const streamUrl = buildStreamUrl(window.location.origin, file.playUrl, name, token);
if (kind === "video") {
return (
<a
href={toPotPlayerUrl(streamUrl)}
title="用本机 PotPlayer 播放"
className="rounded-lg border p-3 text-sm break-all hover:border-primary"
>
<div className="mb-2 flex aspect-video items-center justify-center rounded bg-muted font-mono text-xs tracking-widest text-muted-foreground">
VIDEO
</div>
{name}
<div className="mt-1 text-xs text-muted-foreground">用 PotPlayer 播放</div>
</a>
);
}
return (
<figure className="rounded-lg border p-3 text-sm break-all">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={streamUrl}
alt={name}
loading="lazy"
className="mb-2 aspect-video w-full rounded bg-muted object-contain"
/>
<figcaption>{name}</figcaption>
</figure>
);
};
-80
View File
@@ -1,80 +0,0 @@
"use client";
import { useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useLibrary } from "@/hooks/use-library";
import { useAuthStore } from "@/stores/auth-store";
import { FileCard } from "./file-card";
import { TokenForm } from "./token-form";
// 避免 persist 水合前后 SSR 不一致
const useHydrated = () =>
useSyncExternalStore(
() => () => {},
() => true,
() => false
);
export const LibraryView = () => {
const hydrated = useHydrated();
const token = useAuthStore((state) => state.token);
const clearToken = useAuthStore((state) => state.clearToken);
const { data, isFetching, refetch, isError } = useLibrary();
if (!hydrated) return null;
return (
<div className="mx-auto max-w-[1600px] p-6">
<header className="mb-6 flex items-center gap-3">
<h1 className="mr-auto text-xl font-semibold">TorBox</h1>
{token ? (
<>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
{isFetching && <Spinner />}刷新
</Button>
<Button variant="ghost" onClick={clearToken}>
退出
</Button>
</>
) : (
<TokenForm />
)}
</header>
{!token && <p className="text-muted-foreground">填入 ACCESS_TOKEN 后进入</p>}
{isError && <p className="text-destructive">加载失败,请检查 token 或服务端配置</p>}
{data && (
<div className="grid gap-6">
{Object.entries(data.errors).map(([source, detail]) => (
<p key={source} className="text-sm text-destructive">
{source} 拉取失败:{detail}
</p>
))}
{data.items.length === 0 && <p className="text-muted-foreground">账号里没有资源</p>}
{data.items.map((item) => (
<article key={`${item.source}-${item.id}`}>
<div className="mb-3 flex items-baseline gap-3">
<h2 className="font-semibold break-all">{item.name}</h2>
<small className="text-xs whitespace-nowrap text-muted-foreground">
{item.source}
</small>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{item.files.length === 0 && (
<p className="text-sm text-muted-foreground">暂无文件</p>
)}
{item.files.map((file) => (
<FileCard key={file.id} file={file} token={token} />
))}
</div>
</article>
))}
</div>
)}
</div>
);
};
-31
View File
@@ -1,31 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
export const TokenForm = () => {
const setToken = useAuthStore((state) => state.setToken);
const [value, setValue] = useState("");
return (
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
if (value.trim()) setToken(value.trim());
}}
>
<Input
type="password"
placeholder="ACCESS_TOKEN"
value={value}
onChange={(event) => setValue(event.target.value)}
className="w-72"
/>
<Button type="submit">进入</Button>
</form>
);
};
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { isAxiosError } from "axios";
import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react";
import { BrandMark } from "@/components/brand-mark";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card";
import {
Field,
FieldError,
FieldGroup,
FieldLabel
} from "@/components/ui/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { useLogin } from "@/hooks/use-auth";
const FORM_ID = "login-form";
export const LoginForm = () => {
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 busy = isPending || isSuccess;
return (
<Card className="w-full max-w-sm">
<CardHeader>
<div className="mb-4">
<BrandMark />
</div>
<CardTitle>输入访问密钥</CardTitle>
</CardHeader>
<CardContent>
<form
id={FORM_ID}
onSubmit={(event) => {
event.preventDefault();
if (token.trim()) mutate(token.trim());
}}
>
<FieldGroup>
<Field data-invalid={invalid || undefined}>
<FieldLabel htmlFor="access-token">访问密钥</FieldLabel>
<InputGroup>
<InputGroupAddon>
<KeyRoundIcon />
</InputGroupAddon>
<InputGroupInput
id="access-token"
name="token"
type={visible ? "text" : "password"}
autoComplete="current-password"
autoFocus
value={token}
aria-invalid={invalid || undefined}
onChange={(event) => {
setToken(event.target.value);
if (error) reset();
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={visible ? "隐藏密钥" : "显示密钥"}
onClick={() => setVisible((value) => !value)}
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{invalid ? (
<FieldError>访问密钥不正确,请重新输入</FieldError>
) : null}
</Field>
</FieldGroup>
</form>
</CardContent>
<CardFooter>
<Button
type="submit"
form={FORM_ID}
size="lg"
className="w-full"
disabled={busy || !token.trim()}
>
{busy ? (
<Spinner data-icon="inline-start" />
) : (
<LogInIcon data-icon="inline-start" />
)}
进入
</Button>
</CardFooter>
</Card>
);
};
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { LoginForm } from "./components/login-form";
export const metadata: Metadata = {
title: "登录"
};
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>
);
export default LoginPage;
-5
View File
@@ -1,5 +0,0 @@
import { LibraryView } from "./library/library-view";
const Home = () => <LibraryView />;
export default Home;
+16
View File
@@ -0,0 +1,16 @@
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
</span>
</span>
</div>
);
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+51
View File
@@ -0,0 +1,51 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+267
View File
@@ -0,0 +1,267 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "cn"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+103
View File
@@ -0,0 +1,103 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+65
View File
@@ -0,0 +1,65 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "cn"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+8
View File
@@ -0,0 +1,8 @@
/** 登录态 cookie:保存用户输入的 ACCESS_TOKEN,httpOnly 仅服务端可读 */
export const SESSION_COOKIE = "torbox_admin_token";
/** 登录有效期:30 天(秒) */
export const SESSION_MAX_AGE = 60 * 60 * 24 * 30;
/** 登录接口路径(相对 axios baseURL) */
export const LOGIN_API = "/auth/login";
+8
View File
@@ -1,3 +1,5 @@
import type { SourceType } from "@/types/torbox";
export const TORBOX_API = "https://api.torbox.app/v1/api";
export const SOURCES = [
@@ -16,3 +18,9 @@ export const VIDEO_EXT = new Set([
"mpg", "mpeg", "mpe", "ts", "m2ts", "mts", "vob", "3gp", "3g2", "m3u8", "mpd",
"rm", "rmvb", "asf", "divx", "qt", "h264", "h265", "hevc", "av1"
]);
export const SOURCE_LABELS: Record<SourceType, string> = {
torrents: "Torrent",
usenet: "Usenet",
webdl: "Web DL"
};
+28
View File
@@ -0,0 +1,28 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { login, logout } from "@/services/auth";
export const useLogin = () => {
const router = useRouter();
return useMutation({
mutationFn: login,
onSuccess: () => {
router.replace("/");
router.refresh();
}
});
};
export const useLogout = () => {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.clear();
router.replace("/login");
router.refresh();
}
});
};
+14
View File
@@ -0,0 +1,14 @@
import { useCallback } from "react";
import { message } from "@/lib/message";
import { copyText } from "@/utils/clipboard";
export const useCopy = () =>
useCallback(async (text: string, successText = "已复制到剪贴板") => {
try {
await copyText(text);
message.success(successText);
} catch {
message.error("复制失败,请手动复制");
}
}, []);
+21
View File
@@ -0,0 +1,21 @@
import { useCallback, useState } from "react";
const getContentWidth = (node: HTMLElement) => {
const style = getComputedStyle(node);
return node.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
};
/** 元素内容区宽度(不含内边距),挂载时同步测量,之后随 ResizeObserver 更新 */
export const useElementWidth = <T extends HTMLElement>() => {
const [width, setWidth] = useState(0);
const ref = useCallback((node: T | null) => {
if (!node) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
observer.observe(node);
setWidth(getContentWidth(node));
return () => observer.disconnect();
}, []);
return [ref, width] as const;
};
+90 -6
View File
@@ -1,14 +1,98 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getLibrary } from "@/services/library";
import { useAuthStore } from "@/stores/auth-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";
export const useLibrary = () => {
const token = useAuthStore((state) => state.token);
return useQuery({
queryKey: ["library", token],
export type LibraryFileView = LibraryFile & {
/** 列表展示名(short_name 优先) */
displayName: string;
ext: string;
kind: MediaKind;
};
export type LibraryItemView = Omit<LibraryItem, "files"> & {
files: LibraryFileView[];
totalSize: number;
};
export const useLibrary = () =>
useQuery({
queryKey: ["library"],
queryFn: getLibrary,
enabled: Boolean(token),
retry: false
});
const sumSize = (files: LibraryFile[]) =>
files.reduce((total, file) => total + (file.size ?? 0), 0);
const KIND_ORDER: Record<MediaKind, number> = { video: 0, image: 1, other: 2 };
/** 视频优先,其次图片、其他;同类按文件名自然排序(S01E02 排在 S01E10 前) */
const byKindThenName = (a: LibraryFileView, b: LibraryFileView) =>
KIND_ORDER[a.kind] - KIND_ORDER[b.kind] ||
a.displayName.localeCompare(b.displayName, undefined, { numeric: true });
/** 资源列表 + 工具栏筛选:关键词匹配资源名或文件名,类型过滤文件,来源过滤资源 */
export const useFilteredLibrary = () => {
const query = useLibrary();
const keyword = useLibraryFilterStore((state) => state.keyword);
const source = useLibraryFilterStore((state) => state.source);
const kind = useLibraryFilterStore((state) => state.kind);
const items = useMemo<LibraryItemView[]>(
() =>
(query.data?.items ?? []).map((item) => {
const files = item.files
.map((file) => {
const displayName = getBaseName(file.short_name ?? file.name);
return { ...file, displayName, ext: getFileExt(displayName), kind: getMediaKind(displayName) };
})
.sort(byKindThenName);
return { ...item, files, totalSize: sumSize(files) };
}),
[query.data]
);
const view = useMemo(() => {
const needle = keyword.trim().toLowerCase();
// 先按关键词与类型筛选,来源计数基于这一步的结果
const matched = items.flatMap((item) => {
const nameHit = !needle || item.name.toLowerCase().includes(needle);
const files = item.files.filter(
(file) =>
(kind === "all" || file.kind === kind) &&
(nameHit || file.name.toLowerCase().includes(needle))
);
const keep = files.length > 0 || (nameHit && kind === "all");
return keep ? [{ ...item, files, totalSize: sumSize(files) }] : [];
});
const sourceCounts: Record<SourceFilter, number> = {
all: matched.length,
torrents: 0,
usenet: 0,
webdl: 0
};
for (const item of matched) sourceCounts[item.source] += 1;
const visible = source === "all" ? matched : matched.filter((item) => item.source === source);
return {
items: visible,
sourceCounts,
summary: {
items: visible.length,
files: visible.reduce((total, item) => total + item.files.length, 0),
size: visible.reduce((total, item) => total + item.totalSize, 0)
},
isFiltered: Boolean(needle) || source !== "all" || kind !== "all",
isEmptyLibrary: items.length === 0
};
}, [items, keyword, source, kind]);
return { query, ...view };
};
+11
View File
@@ -0,0 +1,11 @@
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
/** 当前站点 origin;服务端渲染阶段返回空字符串,避免直接在 render 中访问 window */
export const useOrigin = () =>
useSyncExternalStore(
subscribe,
() => window.location.origin,
() => ""
);
+7
View File
@@ -0,0 +1,7 @@
export async function register() {
// 仅在 Node.js 服务端运行时校验,启动失败即暴露配置问题
if (process.env.NEXT_RUNTIME === "nodejs") {
const { assertServerEnv } = await import("@/server/env");
assertServerEnv();
}
}
+9 -14
View File
@@ -1,25 +1,13 @@
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
import { LOGIN_API } from "@/constants/auth";
import { message } from "./message";
// 同源请求自动携带 httpOnly 登录 cookie,无需手动注入 token
const BaseClientService = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api",
timeout: 300000
});
// 请求拦截器
BaseClientService.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
BaseClientService.interceptors.response.use(
(response) => {
@@ -27,6 +15,13 @@ BaseClientService.interceptors.response.use(
},
(error) => {
if (error.response?.status === 401) {
// 登录接口的 401 由登录表单就地提示;其余 401 说明登录态失效,整页跳回登录页以清空客户端缓存
// eslint-disable-next-line @next/next/no-location-assign-relative-destination -- 拦截器不在 React 组件内,无法使用 router
if (error.config?.url !== LOGIN_API) window.location.assign("/login");
return Promise.reject(error);
}
message.error(error.response?.data?.detail ?? error.message);
return Promise.reject(error);
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/constants/auth";
import { verifyAccessToken } from "@/server/auth";
const LOGIN_PATH = "/login";
/** 页面级拦截:未登录统一跳登录页,已登录访问登录页则回首页;API 由各 route 自行鉴权 */
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));
return NextResponse.next();
}
export const config = {
// 排除 API、Next 静态资源与带扩展名的公共文件
matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"]
};
+15 -10
View File
@@ -1,4 +1,7 @@
import { timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/constants/auth";
import { serverEnv } from "@/server/env";
const safeEqual = (a: string, b: string) => {
const x = Buffer.from(a);
@@ -6,24 +9,26 @@ const safeEqual = (a: string, b: string) => {
return x.length === y.length && timingSafeEqual(x, y);
};
/** 校验 Bearer 头或 ?token= 与环境变量 ACCESS_TOKEN 是否一致 */
export const isAuthorized = (req: Request) => {
const expected = process.env.ACCESS_TOKEN;
if (!expected) return false;
/** 校验传入值是否等于环境变量 ACCESS_TOKEN */
export const verifyAccessToken = (value: string | null | undefined): value is string =>
Boolean(value) && safeEqual(value!, serverEnv("ACCESS_TOKEN"));
/** 校验登录 cookie、Bearer 头或 ?token=(供 PotPlayer 等外部播放器使用)任一有效 */
export const isAuthorized = (req: NextRequest) => {
const header = req.headers.get("authorization") ?? "";
const bearer = header.startsWith("Bearer ") ? header.slice(7) : "";
const query = new URL(req.url).searchParams.get("token") ?? "";
return safeEqual(bearer, expected) || safeEqual(query, expected);
return (
verifyAccessToken(req.cookies.get(SESSION_COOKIE)?.value) ||
verifyAccessToken(bearer) ||
verifyAccessToken(req.nextUrl.searchParams.get("token"))
);
};
export const jsonError = (status: number, detail: string) =>
Response.json({ success: false, detail }, { status, headers: { "Cache-Control": "no-store" } });
/** 统一入口:返回 Response 表示已拒绝,返回 null 表示通过 */
export const guard = (req: Request) => {
if (!process.env.ACCESS_TOKEN || !process.env.TORBOX_API_KEY) {
return jsonError(500, "服务端未配置 ACCESS_TOKEN 或 TORBOX_API_KEY");
}
export const guard = (req: NextRequest) => {
if (!isAuthorized(req)) return jsonError(401, "bad token");
return null;
};
+14
View File
@@ -0,0 +1,14 @@
const REQUIRED_ENV = ["ACCESS_TOKEN", "TORBOX_API_KEY"] as const;
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 或容器环境中配置后重启`);
}
};
/** 读取已在启动时校验过的密钥;仅限服务端使用 */
export const serverEnv = (name: RequiredEnv) => process.env[name] as string;
+11
View File
@@ -0,0 +1,11 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE } from "@/constants/auth";
import { verifyAccessToken } from "@/server/auth";
/** 读取并校验登录 cookie,未登录则跳转登录页;返回的 token 用于拼接播放器直链 */
export const requireSessionToken = async () => {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!verifyAccessToken(token)) redirect("/login");
return token;
};
+13 -4
View File
@@ -1,19 +1,24 @@
import { serverEnv } from "@/server/env";
import { SOURCES, TORBOX_API } from "@/constants/torbox";
import type { 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[] };
const apiKey = () => process.env.TORBOX_API_KEY ?? "";
const apiKey = () => serverEnv("TORBOX_API_KEY");
const torboxFetch = async (path: string) => {
const res = await fetch(TORBOX_API + path, {
headers: { Authorization: `Bearer ${apiKey()}`, Accept: "application/json" },
headers: {
Authorization: `Bearer ${apiKey()}`,
Accept: "application/json"
},
signal: AbortSignal.timeout(15000),
cache: "no-store"
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.success === false) throw new Error(body.detail ?? res.statusText);
if (!res.ok || body.success === false)
throw new Error(body.detail ?? res.statusText);
return body.data;
};
@@ -49,7 +54,11 @@ export const getLibrary = async (): Promise<LibraryResponse> => {
};
/** 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端) */
export const requestDownloadUrl = async (type: SourceType, id: string, fileId: string) => {
export const requestDownloadUrl = async (
type: SourceType,
id: string,
fileId: string
) => {
const source = SOURCES.find((s) => s.type === type)!;
const params = new URLSearchParams({
token: apiKey(),
+10
View File
@@ -0,0 +1,10 @@
import { LOGIN_API } from "@/constants/auth";
import BaseClientService from "@/lib/axios";
export const login = async (token: string) => {
await BaseClientService.post(LOGIN_API, { token });
};
export const logout = async () => {
await BaseClientService.post("/auth/logout");
};
-19
View File
@@ -1,19 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
type AuthState = {
token: string;
setToken: (token: string) => void;
clearToken: () => void;
};
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: "",
setToken: (token) => set({ token }),
clearToken: () => set({ token: "" })
}),
{ name: "torbox-admin-auth" }
)
);
+28
View File
@@ -0,0 +1,28 @@
import { create } from "zustand";
import type { SourceType } from "@/types/torbox";
import type { MediaKind } from "@/utils/media";
export type SourceFilter = "all" | SourceType;
export type KindFilter = "all" | MediaKind;
type LibraryFilterState = {
keyword: string;
source: SourceFilter;
kind: KindFilter;
setKeyword: (keyword: string) => void;
setSource: (source: SourceFilter) => void;
setKind: (kind: KindFilter) => void;
reset: () => void;
};
const initialFilter = { keyword: "", source: "all", kind: "all" } as const;
/** 文件列表页的筛选条件(工具栏与列表共享),不持久化 */
export const useLibraryFilterStore = create<LibraryFilterState>()((set) => ({
...initialFilter,
setKeyword: (keyword) => set({ keyword }),
setSource: (source) => set({ source }),
setKind: (kind) => set({ kind }),
reset: () => set(initialFilter)
}));
+43
View File
@@ -0,0 +1,43 @@
import type { VirtualItem } from "@tanstack/react-virtual";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
/** 资源列表滚动快照:以首个可见行为锚点,数据增删后仍能回到原位置 */
export type LibraryScrollSnapshot = {
/** 生成快照时的筛选条件,不一致时不恢复 */
signature: string;
anchorKey: string;
/** 滚动位置相对锚点行顶部的偏移 */
anchorDelta: number;
/** 已测量行的尺寸,恢复时作为 initialMeasurementsCache */
measurements: VirtualItem[];
};
type LibraryViewState = {
/** 已收起的资源(`${source}-${id}`) */
collapsed: string[];
snapshot: LibraryScrollSnapshot | null;
toggleCollapsed: (key: string) => void;
saveSnapshot: (snapshot: LibraryScrollSnapshot) => void;
};
/** 资源列表的界面状态,存于 sessionStorage:刷新页面后恢复折叠与滚动位置 */
export const useLibraryViewStore = create<LibraryViewState>()(
persist(
(set) => ({
collapsed: [],
snapshot: null,
toggleCollapsed: (key) =>
set((state) => ({
collapsed: state.collapsed.includes(key)
? state.collapsed.filter((item) => item !== key)
: [...state.collapsed, key]
})),
saveSnapshot: (snapshot) => set({ snapshot })
}),
{
name: "torbox-admin-library-view",
storage: createJSONStorage(() => sessionStorage)
}
)
);
+16
View File
@@ -0,0 +1,16 @@
/** 兼容非安全上下文(局域网 http 部署时 navigator.clipboard 不可用)的复制 */
export const copyText = async (text: string) => {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) throw new Error("copy failed");
};
+16
View File
@@ -0,0 +1,16 @@
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
/** 以 1024 进制格式化字节数,如 4.3 GB;未知大小返回 "—" */
export const formatBytes = (bytes?: number) => {
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "—";
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit === 0 || value >= 100 ? 0 : 1)} ${BYTE_UNITS[unit]}`;
};
/** 千分位整数,如 1,024 */
export const formatCount = (value: number) => value.toLocaleString("en-US");
+18 -3
View File
@@ -2,10 +2,18 @@ import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox";
export type MediaKind = "video" | "image" | "other";
export const getMediaKind = (name: string): MediaKind => {
const base = name.split(/[\\/]/).pop() ?? "";
/** 取路径最后一段作为文件名 */
export const getBaseName = (name: string) => name.split(/[\\/]/).pop() ?? "";
/** 小写扩展名,没有扩展名时返回空字符串 */
export const getFileExt = (name: string) => {
const base = getBaseName(name);
const dot = base.lastIndexOf(".");
const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
};
export const getMediaKind = (name: string): MediaKind => {
const ext = getFileExt(name);
if (VIDEO_EXT.has(ext)) return "video";
if (IMAGE_EXT.has(ext)) return "image";
return "other";
@@ -26,3 +34,10 @@ export const buildStreamUrl = (
};
export const toPotPlayerUrl = (streamUrl: string) => `potplayer://${streamUrl}`;
/** 全部资源的 M3U 播放列表地址,供 PotPlayer 等播放器直接打开 */
export const buildPlaylistUrl = (origin: string, token: string) => {
const url = new URL("/api/playlist", origin);
url.searchParams.set("token", token);
return url.toString();
};