feat: init

This commit is contained in:
2026-10-05 04:46:15 -07:00
commit 10a1983de0
48 changed files with 8160 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.gitea
.idea
.next
.agents
.claude
.hermes
node_modules
.env*
*.tsbuildinfo
next-env.d.ts
AGENTS.md
CLAUDE.md
README.md
+4
View File
@@ -0,0 +1,4 @@
# TorBox API Key,仅服务端使用,不会下发到浏览器
TORBOX_API_KEY=
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
ACCESS_TOKEN=
+49
View File
@@ -0,0 +1,49 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/.agents/
/.claude/
/.hermes/
AGENTS.md
CLAUDE.md
+38
View File
@@ -0,0 +1,38 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm@10
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# NEXT_PUBLIC_* 会在构建时内联进浏览器代码,必须通过构建参数传入
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
NEXT_TELEMETRY_DISABLED=1
COPY . .
RUN pnpm build
FROM node:22-alpine
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
HOSTNAME=127.0.0.1 \
PORT=22786
WORKDIR /app
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
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 ["node", "server.js"]
+8
View File
@@ -0,0 +1,8 @@
# TorBox Admin
基于 Next.js App Router 的 TorBox 第三方管理页
```bash
pnpm install
pnpm dev # http://localhost:3200
```
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import tanstackQuery from "@tanstack/eslint-plugin-query";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
plugins: {
"@tanstack/query": tanstackQuery
},
rules: {
...tanstackQuery.configs["flat/recommended"].rules
}
},
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"])
]);
export default eslintConfig;
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// 生成可独立运行的 server.js,供 Docker 镜像使用
output: "standalone"
};
export default nextConfig;
+38
View File
@@ -0,0 +1,38 @@
{
"name": "torbox-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3200",
"build": "next build",
"start": "next start -p 3200",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.8.0",
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.3.2",
"lucide-react": "^1.47.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tanstack/eslint-plugin-query": "^5.103.2",
"@tanstack/react-query-devtools": "^5.103.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"shadcn": "^4.21.0",
"tailwindcss": "^4",
"typescript": "^5.9.3"
},
"packageManager": "pnpm@10.34.5"
}
+6526
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
sharp: false
unrs-resolver: false
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"shadcn": {
"source": "shadcn/ui",
"sourceType": "github",
"skillPath": "skills/shadcn/SKILL.md",
"computedHash": "d04e43ec01e921558b93bb8046c14a42a22556689a2827243df900825a4e1604"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import { guard, jsonError } from "@/server/auth";
import { getLibrary } from "@/server/torbox";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
const denied = guard(req);
if (denied) return denied;
try {
return Response.json(
{ success: true, data: await getLibrary() },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
return jsonError(502, error instanceof Error ? error.message : "upstream error");
}
}
+27
View File
@@ -0,0 +1,27 @@
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) {
const denied = guard(req);
if (denied) return denied;
try {
const { items } = await getLibrary();
const { origin, searchParams } = new URL(req.url);
const token = searchParams.get("token") ?? "";
const lines = ["#EXTM3U"];
for (const item of items) {
for (const file of item.files) {
const title = `${item.name} / ${file.short_name ?? file.name}`.replace(/[\r\n,]/g, " ");
lines.push(`#EXTINF:-1,${title}`, buildStreamUrl(origin, file.playUrl, file.name, token));
}
}
return new Response(lines.join("\r\n") + "\r\n", {
headers: { "Content-Type": "audio/x-mpegurl; charset=utf-8", "Cache-Control": "no-store" }
});
} catch (error) {
return jsonError(502, error instanceof Error ? error.message : "upstream error");
}
}
@@ -0,0 +1,52 @@
import { guard, jsonError } from "@/server/auth";
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
export const dynamic = "force-dynamic";
// 透传给播放器的响应头,支持 Range 拖动进度
const PASS_HEADERS = [
"content-type",
"content-length",
"content-range",
"accept-ranges",
"last-modified",
"etag"
];
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
const proxy = async (req: Request, { params }: Ctx, method: "GET" | "HEAD") => {
const denied = guard(req);
if (denied) return denied;
const { type, id, fileId } = await params;
if (!isSourceType(type) || !/^\d+$/.test(id) || !/^\d+$/.test(fileId)) {
return jsonError(404, "unknown source");
}
try {
const url = await requestDownloadUrl(type, id, fileId);
const range = req.headers.get("range");
const upstream = await fetch(url, {
method,
headers: range ? { Range: range } : undefined,
signal: req.signal,
cache: "no-store"
});
const headers = new Headers({ "Cache-Control": "no-store" });
for (const name of PASS_HEADERS) {
const value = upstream.headers.get(name);
if (value) headers.set(name, value);
}
return new Response(method === "HEAD" ? null : upstream.body, {
status: upstream.status,
headers
});
} catch (error) {
return jsonError(502, error instanceof Error ? error.message : "upstream error");
}
};
export const GET = (req: Request, ctx: Ctx) => proxy(req, ctx, "GET");
export const HEAD = (req: Request, ctx: Ctx) => proxy(req, ctx, "HEAD");
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--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);
}
.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);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+26
View File
@@ -0,0 +1,26 @@
import "./globals.css";
import { Toaster } from "@/components/ui/toast";
import QueryProvider from "@/providers/query-provider";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "TorBox Admin",
template: "%s | TorBox Admin"
}
};
export default function RootLayout({
children
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<QueryProvider>{children}</QueryProvider>
<Toaster />
</body>
</html>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import type { LibraryFile } from "@/types/torbox";
import { buildStreamUrl, getMediaKind, toPotPlayerUrl } from "@/utils/media";
export const FileCard = ({ file, token }: { file: LibraryFile; token: string }) => {
const name = file.short_name ?? file.name;
const kind = getMediaKind(name);
if (kind === "other") {
return (
<div className="rounded-lg border border-dashed p-3 text-sm break-all text-muted-foreground">
{name}
</div>
);
}
const streamUrl = buildStreamUrl(window.location.origin, file.playUrl, name, token);
if (kind === "video") {
return (
<a
href={toPotPlayerUrl(streamUrl)}
title="用本机 PotPlayer 播放"
className="rounded-lg border p-3 text-sm break-all hover:border-primary"
>
<div className="mb-2 flex aspect-video items-center justify-center rounded bg-muted font-mono text-xs tracking-widest text-muted-foreground">
VIDEO
</div>
{name}
<div className="mt-1 text-xs text-muted-foreground">用 PotPlayer 播放</div>
</a>
);
}
return (
<figure className="rounded-lg border p-3 text-sm break-all">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={streamUrl}
alt={name}
loading="lazy"
className="mb-2 aspect-video w-full rounded bg-muted object-contain"
/>
<figcaption>{name}</figcaption>
</figure>
);
};
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useLibrary } from "@/hooks/use-library";
import { useAuthStore } from "@/stores/auth-store";
import { FileCard } from "./file-card";
import { TokenForm } from "./token-form";
// 避免 persist 水合前后 SSR 不一致
const useHydrated = () =>
useSyncExternalStore(
() => () => {},
() => true,
() => false
);
export const LibraryView = () => {
const hydrated = useHydrated();
const token = useAuthStore((state) => state.token);
const clearToken = useAuthStore((state) => state.clearToken);
const { data, isFetching, refetch, isError } = useLibrary();
if (!hydrated) return null;
return (
<div className="mx-auto max-w-[1600px] p-6">
<header className="mb-6 flex items-center gap-3">
<h1 className="mr-auto text-xl font-semibold">TorBox</h1>
{token ? (
<>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
{isFetching && <Spinner />}刷新
</Button>
<Button variant="ghost" onClick={clearToken}>
退出
</Button>
</>
) : (
<TokenForm />
)}
</header>
{!token && <p className="text-muted-foreground">填入 ACCESS_TOKEN 后进入</p>}
{isError && <p className="text-destructive">加载失败,请检查 token 或服务端配置</p>}
{data && (
<div className="grid gap-6">
{Object.entries(data.errors).map(([source, detail]) => (
<p key={source} className="text-sm text-destructive">
{source} 拉取失败:{detail}
</p>
))}
{data.items.length === 0 && <p className="text-muted-foreground">账号里没有资源</p>}
{data.items.map((item) => (
<article key={`${item.source}-${item.id}`}>
<div className="mb-3 flex items-baseline gap-3">
<h2 className="font-semibold break-all">{item.name}</h2>
<small className="text-xs whitespace-nowrap text-muted-foreground">
{item.source}
</small>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{item.files.length === 0 && (
<p className="text-sm text-muted-foreground">暂无文件</p>
)}
{item.files.map((file) => (
<FileCard key={file.id} file={file} token={token} />
))}
</div>
</article>
))}
</div>
)}
</div>
);
};
+31
View File
@@ -0,0 +1,31 @@
"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>
);
};
+5
View File
@@ -0,0 +1,5 @@
import { LibraryView } from "./library/library-view";
const Home = () => <LibraryView />;
export default Home;
+57
View File
@@ -0,0 +1,57 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg 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 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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 { Input }
+19
View File
@@ -0,0 +1,19 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "cn"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "cn"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
+252
View File
@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import { Toast as ToastPrimitive } from "@base-ui/react/toast";
import { ClassValue, cn } from "cn";
import { Button } from "@/components/ui/button";
import {
XIcon,
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon
} from "lucide-react";
const toast = ToastPrimitive.createToastManager();
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
return <ToastPrimitive.Provider {...props} />;
}
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />;
}
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
return (
<ToastPrimitive.Viewport
data-slot="toast-viewport"
className={cn(
"pointer-events-none fixed top-4 left-1/2 z-50 w-full max-w-sm -translate-x-1/2 outline-none",
className
)}
{...props}
/>
);
}
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
return (
<ToastPrimitive.Root
data-slot="toast"
className={cn(
"group/toast pointer-events-auto absolute top-0 left-0 z-[calc(1000-var(--toast-index))] w-full origin-top rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
// 顶部堆叠方向
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)+calc(var(--toast-index)*var(--gap))+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
// 默认状态
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--peek))+(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
// 堆叠间距
"after:absolute after:bottom-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
// 展开
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
// 出现:从顶部进入
"data-limited:opacity-0 data-starting-style:[transform:translateY(-150%)]",
// 默认消失:向上离开
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(-150%)]",
// 手势方向
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
// expanded 状态退出
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
className
)}
{...props}
/>
);
}
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
return (
<ToastPrimitive.Content
data-slot="toast-content"
className={cn(
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
className
)}
{...props}
/>
);
}
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
return (
<ToastPrimitive.Title
data-slot="toast-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
);
}
function ToastDescription({
className,
...props
}: ToastPrimitive.Description.Props) {
return (
<ToastPrimitive.Description
data-slot="toast-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function ToastAction({
className,
render = <Button variant="outline" size="sm" />,
...props
}: ToastPrimitive.Action.Props) {
return (
<ToastPrimitive.Action
data-slot="toast-action"
render={render}
className={cn("shrink-0", className)}
{...props}
/>
);
}
function ToastClose({
className,
children,
render = <Button variant="ghost" size="icon-sm" />,
...props
}: ToastPrimitive.Close.Props) {
return (
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close toast"
render={render}
className={cn(
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
className
)}
{...props}
>
{children ?? <XIcon aria-hidden="true" />}
</ToastPrimitive.Close>
);
}
function ToastIcon({ type }: { type: string | undefined }) {
let icon: React.ReactNode = null;
if (type === "success") {
icon = <CircleCheckIcon aria-hidden="true" />;
}
if (type === "info") {
icon = <InfoIcon aria-hidden="true" />;
}
if (type === "warning") {
icon = <TriangleAlertIcon aria-hidden="true" />;
}
if (type === "error") {
icon = <OctagonXIcon className="text-destructive" aria-hidden="true" />;
}
if (type === "loading") {
icon = <Loader2Icon className="animate-spin" aria-hidden="true" />;
}
if (!icon) {
return null;
}
return (
<span
data-slot="toast-icon"
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
>
{icon}
</span>
);
}
function ToastList() {
const { toasts } = ToastPrimitive.useToastManager();
return toasts.map((toastItem) => (
<Toast key={toastItem.id} toast={toastItem}>
<ToastContent>
<ToastIcon type={toastItem.type} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<ToastTitle />
<ToastDescription />
</div>
<ToastAction />
<ToastClose />
</ToastContent>
</Toast>
));
}
type ToasterProps = ToastPrimitive.Provider.Props & {
viewportClassName?: ClassValue;
};
function Toaster({
children,
toastManager = toast,
viewportClassName,
...props
}: ToasterProps) {
return (
<ToastProvider toastManager={toastManager} {...props}>
{children}
<ToastPortal>
<ToastViewport className={cn(viewportClassName)}>
<ToastList />
</ToastViewport>
</ToastPortal>
</ToastProvider>
);
}
const createToastManager = ToastPrimitive.createToastManager;
const useToastManager = ToastPrimitive.useToastManager;
export {
Toaster,
Toast,
ToastAction,
ToastClose,
ToastContent,
ToastDescription,
ToastPortal,
ToastProvider,
ToastTitle,
ToastViewport,
createToastManager,
toast,
useToastManager
};
+18
View File
@@ -0,0 +1,18 @@
export const TORBOX_API = "https://api.torbox.app/v1/api";
export const SOURCES = [
{ type: "torrents", list: "/torrents/mylist", idName: "torrent_id" },
{ type: "usenet", list: "/usenet/mylist", idName: "usenet_id" },
{ type: "webdl", list: "/webdl/mylist", idName: "web_id" }
] as const;
export const IMAGE_EXT = new Set([
"jpg", "jpeg", "png", "gif", "webp", "avif", "apng", "svg", "svgz", "ico",
"bmp", "tif", "tiff", "tga", "jxl", "heic", "heif"
]);
export const VIDEO_EXT = new Set([
"mp4", "webm", "ogv", "ogg", "m4v", "mov", "avi", "mkv", "wmv", "flv", "f4v",
"mpg", "mpeg", "mpe", "ts", "m2ts", "mts", "vob", "3gp", "3g2", "m3u8", "mpd",
"rm", "rmvb", "asf", "divx", "qt", "h264", "h265", "hevc", "av1"
]);
+14
View File
@@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { getLibrary } from "@/services/library";
import { useAuthStore } from "@/stores/auth-store";
export const useLibrary = () => {
const token = useAuthStore((state) => state.token);
return useQuery({
queryKey: ["library", token],
queryFn: getLibrary,
enabled: Boolean(token),
retry: false
});
};
+36
View File
@@ -0,0 +1,36 @@
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
import { message } from "./message";
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) => {
return response;
},
(error) => {
message.error(error.response?.data?.detail ?? error.message);
return Promise.reject(error);
}
);
export default BaseClientService;
+48
View File
@@ -0,0 +1,48 @@
import { toast } from "@/components/ui/toast";
type MessageOptions = {
description?: string;
priority?: "high" | "low";
};
export const message = {
success(description: string, options?: MessageOptions) {
toast.add({
type: "success",
description,
...options
});
},
error(description: string, options?: MessageOptions) {
toast.add({
type: "error",
description,
priority: "high",
...options
});
},
info(description: string, options?: MessageOptions) {
toast.add({
type: "info",
description,
...options
});
},
warning(description: string, options?: MessageOptions) {
toast.add({
type: "warning",
description,
...options
});
},
default(description: string, options?: MessageOptions) {
toast.add({
description,
...options
});
}
};
+12
View File
@@ -0,0 +1,12 @@
import { QueryClient } from "@tanstack/react-query";
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1
}
}
});
}
+1
View File
@@ -0,0 +1 @@
export { cn } from "cn"
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { makeQueryClient } from "@/lib/query-client";
export default function QueryProvider({
children
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(() => makeQueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { timingSafeEqual } from "node:crypto";
const safeEqual = (a: string, b: string) => {
const x = Buffer.from(a);
const y = Buffer.from(b);
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;
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);
};
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");
}
if (!isAuthorized(req)) return jsonError(401, "bad token");
return null;
};
+63
View File
@@ -0,0 +1,63 @@
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 torboxFetch = async (path: string) => {
const res = await fetch(TORBOX_API + path, {
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);
return body.data;
};
export const isSourceType = (value: string): value is SourceType =>
SOURCES.some((source) => source.type === value);
export const getLibrary = async (): Promise<LibraryResponse> => {
const errors: LibraryResponse["errors"] = {};
const items: LibraryItem[] = [];
await Promise.all(
SOURCES.map(async ({ type, list }) => {
try {
const data: RawItem[] = (await torboxFetch(`${list}?limit=1000`)) ?? [];
for (const item of data) {
items.push({
id: item.id,
name: item.name,
source: type,
files: (item.files ?? []).map((file) => ({
...file,
playUrl: `/api/stream/${type}/${item.id}/${file.id}`
}))
});
}
} catch (error) {
errors[type] = error instanceof Error ? error.message : "unknown";
}
})
);
return { items, errors };
};
/** 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端) */
export const requestDownloadUrl = async (type: SourceType, id: string, fileId: string) => {
const source = SOURCES.find((s) => s.type === type)!;
const params = new URLSearchParams({
token: apiKey(),
[source.idName]: id,
file_id: fileId,
redirect: "false"
});
const url = await torboxFetch(`/${type}/requestdl?${params}`);
if (typeof url !== "string" || !url) throw new Error("TorBox 未返回下载地址");
return url;
};
+9
View File
@@ -0,0 +1,9 @@
import BaseClientService from "@/lib/axios";
import type { LibraryResponse } from "@/types/torbox";
export const getLibrary = async () => {
const { data } = await BaseClientService.get<{ success: boolean; data: LibraryResponse }>(
"/library"
);
return data.data;
};
+19
View File
@@ -0,0 +1,19 @@
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" }
)
);
+23
View File
@@ -0,0 +1,23 @@
export type SourceType = "torrents" | "usenet" | "webdl";
export type LibraryFile = {
id: number;
name: string;
short_name?: string;
size?: number;
/** 服务端生成的相对播放路径,不含 token */
playUrl: string;
};
export type LibraryItem = {
id: number;
name: string;
source: SourceType;
files: LibraryFile[];
};
export type LibraryResponse = {
items: LibraryItem[];
/** 拉取失败的来源及原因 */
errors: Partial<Record<SourceType, string>>;
};
+28
View File
@@ -0,0 +1,28 @@
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() ?? "";
const dot = base.lastIndexOf(".");
const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
if (VIDEO_EXT.has(ext)) return "video";
if (IMAGE_EXT.has(ext)) return "image";
return "other";
};
/** 拼接带文件名后缀和 token 的完整流地址;文件名仅供播放器显示 */
export const buildStreamUrl = (
origin: string,
playUrl: string,
fileName: string,
token: string
) => {
const url = new URL(playUrl, origin);
const base = fileName.split(/[\\/]/).pop();
if (base && base !== "." && base !== "..") url.pathname += "/" + encodeURIComponent(base);
url.searchParams.set("token", token);
return url.toString();
};
export const toPotPlayerUrl = (streamUrl: string) => `potplayer://${streamUrl}`;
+42
View File
@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}