feat: 完善页面内容
This commit is contained in:
@@ -47,3 +47,5 @@ next-env.d.ts
|
||||
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
.vscode
|
||||
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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])} />
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LibraryView } from "./library/library-view";
|
||||
|
||||
const Home = () => <LibraryView />;
|
||||
|
||||
export default Home;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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("复制失败,请手动复制");
|
||||
}
|
||||
}, []);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = () => () => {};
|
||||
|
||||
/** 当前站点 origin;服务端渲染阶段返回空字符串,避免直接在 render 中访问 window */
|
||||
export const useOrigin = () =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.location.origin,
|
||||
() => ""
|
||||
);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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" }
|
||||
)
|
||||
);
|
||||
@@ -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)
|
||||
}));
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user