feat: init
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# TorBox API Key,仅服务端使用,不会下发到浏览器
|
||||
TORBOX_API_KEY=
|
||||
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
|
||||
ACCESS_TOKEN=
|
||||
+49
@@ -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
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
# TorBox Admin
|
||||
|
||||
基于 Next.js App Router 的 TorBox 第三方管理页
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:3200
|
||||
```
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// 生成可独立运行的 server.js,供 Docker 镜像使用
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+6526
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
sharp: false
|
||||
unrs-resolver: false
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"shadcn": {
|
||||
"source": "shadcn/ui",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/shadcn/SKILL.md",
|
||||
"computedHash": "d04e43ec01e921558b93bb8046c14a42a22556689a2827243df900825a4e1604"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LibraryView } from "./library/library-view";
|
||||
|
||||
const Home = () => <LibraryView />;
|
||||
|
||||
export default Home;
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
};
|
||||
@@ -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"
|
||||
]);
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cn } from "cn"
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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" }
|
||||
)
|
||||
);
|
||||
@@ -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>>;
|
||||
};
|
||||
@@ -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}`;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user