feat: 一些优化
torbox-admin / deploy (push) Successful in 2m44s

This commit is contained in:
2026-10-07 03:36:49 -07:00
parent 39b5093abb
commit 5754abe713
22 changed files with 966 additions and 400 deletions
+6 -1
View File
@@ -1,8 +1,13 @@
import { codeInspectorPlugin } from "code-inspector-plugin";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// 生成可独立运行的 server.js,供 Docker 镜像使用
output: "standalone"
output: "standalone",
// 开发时 Option+Shift(Windows: Alt+Shift)点击页面元素跳转到源码
turbopack: {
rules: codeInspectorPlugin({ bundler: "turbopack" })
}
};
export default nextConfig;
+1
View File
@@ -42,6 +42,7 @@
"@types/node": "^22.20.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"code-inspector-plugin": "^2.0.9",
"drizzle-kit": "1.0.0-rc.4",
"eslint": "^9",
"eslint-config-next": "16.3.5",
+225
View File
@@ -87,6 +87,9 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.3.0(@types/react@19.3.0)
code-inspector-plugin:
specifier: ^2.0.9
version: 2.0.9
drizzle-kit:
specifier: 1.0.0-rc.4
version: 1.0.0-rc.4
@@ -278,6 +281,35 @@ packages:
'@types/react':
optional: true
'@code-inspector/core@2.0.9':
resolution: {integrity: sha512-7WkUlXfpwM95+KY5ra3KyivQGy0fUBq9dFcoI481LJKKnZNzwRW6kUkQF2hVP7zTj8TDy5esCMw8YuOwI8HhsQ==}
peerDependencies:
'@anthropic-ai/claude-agent-sdk': ^0.2.29
'@openai/codex-sdk': ^0.106.0
'@opencode-ai/sdk': ^1.2.24
peerDependenciesMeta:
'@anthropic-ai/claude-agent-sdk':
optional: true
'@openai/codex-sdk':
optional: true
'@opencode-ai/sdk':
optional: true
'@code-inspector/esbuild@2.0.9':
resolution: {integrity: sha512-uiddojMyD4HUtJ8pVq4/Iu0AgILaRZoe/zXJUK2ibqWdP6XtjIWCTLfvDL8IO9tOOxuEi+0SsRwr/Y8CEN5sTQ==}
'@code-inspector/mako@2.0.9':
resolution: {integrity: sha512-HGb8oHXEj2fGnD5AtdUzFhMXkuRAXenKT1iCV53B8gFgiV5ddSrln+/mPQZt3v3FTu3TyQNd3aaHtPpSW+B0vg==}
'@code-inspector/turbopack@2.0.9':
resolution: {integrity: sha512-uNTPCkDxS+4htXOKTJf+yEjYqKDFLIczILQ41dMqD5z9rbSDJkU/bvMs+8RcGUks0f7cB+TtRQyQCfwoFW1atA==}
'@code-inspector/vite@2.0.9':
resolution: {integrity: sha512-XXwb3Cyz70Y8OqP7H7umKVM1RrfmWtKBZ7JrrtlwraEaRRefo70+guuQZr6vuNm8lOT2VHgsG1bH9wqs7WrrXw==}
'@code-inspector/webpack@2.0.9':
resolution: {integrity: sha512-sauXo+GD6QIlqSymNtDTQOyrQid8er71Kr3Jh07viUKd1+U10//EML/tb/IpOzutwauizJeQslKqAvDV+Nqc9w==}
'@corvu/utils@0.4.2':
resolution: {integrity: sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==}
peerDependencies:
@@ -1211,6 +1243,15 @@ packages:
'@videojs/utils@10.0.1':
resolution: {integrity: sha512-S7WO9lPfyCz5YQYWtpE42JZTtJKUaiWaAZGAa+VLoBiFMfpoogCloSP4pjChTYcQ6sGcLlQJNxVKhM1RvI/Dig==}
'@vue/compiler-core@3.5.43':
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
'@vue/compiler-dom@3.5.43':
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
'@vue/shared@3.5.43':
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1328,6 +1369,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -1420,6 +1464,10 @@ packages:
resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==}
engines: {node: '>=10'}
chalk@4.1.1:
resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==}
engines: {node: '>=10'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -1455,6 +1503,9 @@ packages:
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
code-inspector-plugin@2.0.9:
resolution: {integrity: sha512-0CApBNHqPTrcbOL/1n4JJS0Wy0CGYTRmJVWY8qI0wkYmmeT/LhaQZ1EIGQNuHLepZn4SA/XWd+4No+8gshpRNA==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1636,6 +1687,10 @@ packages:
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
engines: {node: '>=10'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
@@ -1824,6 +1879,10 @@ packages:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -2004,6 +2063,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -2628,6 +2690,9 @@ packages:
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
engines: {node: '>=0.10'}
launch-ide@1.4.10:
resolution: {integrity: sha512-MqkYz5/dYDtBkvcDmz6tr2uFWU8AqRnrI+kLwcVmV83QZ/uhN0yns9GUQyLZJ5CJ5pglm8xko65JGcyzyI3dBQ==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -2865,10 +2930,16 @@ packages:
sass:
optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-exports-info@1.6.2:
resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
engines: {node: '>= 0.4'}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-releases@2.0.56:
resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==}
engines: {node: '>=18'}
@@ -3065,6 +3136,10 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
portfinder@1.0.38:
resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==}
engines: {node: '>= 10.12'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -3670,6 +3745,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.22.0:
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@1.0.0:
resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==}
engines: {node: '>=20'}
@@ -3953,6 +4040,77 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
'@code-inspector/core@2.0.9':
dependencies:
'@vue/compiler-dom': 3.5.43
chalk: 4.1.1
launch-ide: 1.4.10
portfinder: 1.0.38
ws: 8.22.0
optionalDependencies:
node-pty: 1.1.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@code-inspector/esbuild@2.0.9':
dependencies:
'@code-inspector/core': 2.0.9
transitivePeerDependencies:
- '@anthropic-ai/claude-agent-sdk'
- '@openai/codex-sdk'
- '@opencode-ai/sdk'
- bufferutil
- supports-color
- utf-8-validate
'@code-inspector/mako@2.0.9':
dependencies:
'@code-inspector/core': 2.0.9
transitivePeerDependencies:
- '@anthropic-ai/claude-agent-sdk'
- '@openai/codex-sdk'
- '@opencode-ai/sdk'
- bufferutil
- supports-color
- utf-8-validate
'@code-inspector/turbopack@2.0.9':
dependencies:
'@code-inspector/core': 2.0.9
'@code-inspector/webpack': 2.0.9
transitivePeerDependencies:
- '@anthropic-ai/claude-agent-sdk'
- '@openai/codex-sdk'
- '@opencode-ai/sdk'
- bufferutil
- supports-color
- utf-8-validate
'@code-inspector/vite@2.0.9':
dependencies:
'@code-inspector/core': 2.0.9
chalk: 4.1.1
transitivePeerDependencies:
- '@anthropic-ai/claude-agent-sdk'
- '@openai/codex-sdk'
- '@opencode-ai/sdk'
- bufferutil
- supports-color
- utf-8-validate
'@code-inspector/webpack@2.0.9':
dependencies:
'@code-inspector/core': 2.0.9
transitivePeerDependencies:
- '@anthropic-ai/claude-agent-sdk'
- '@openai/codex-sdk'
- '@opencode-ai/sdk'
- bufferutil
- supports-color
- utf-8-validate
'@corvu/utils@0.4.2(solid-js@1.9.15)':
dependencies:
'@floating-ui/dom': 1.8.0
@@ -4781,6 +4939,21 @@ snapshots:
'@videojs/utils@10.0.1': {}
'@vue/compiler-core@3.5.43':
dependencies:
'@babel/parser': 7.29.9
'@vue/shared': 3.5.43
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.43':
dependencies:
'@vue/compiler-core': 3.5.43
'@vue/shared': 3.5.43
'@vue/shared@3.5.43': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -4917,6 +5090,8 @@ snapshots:
async-function@1.0.0: {}
async@3.2.6: {}
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
@@ -5018,6 +5193,11 @@ snapshots:
ansi-styles: 4.1.0
supports-color: 7.1.0
chalk@4.1.1:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.1.0
chalk@5.6.2: {}
class-variance-authority@0.7.1:
@@ -5040,6 +5220,23 @@ snapshots:
code-block-writer@13.0.3: {}
code-inspector-plugin@2.0.9:
dependencies:
'@code-inspector/core': 2.0.9
'@code-inspector/esbuild': 2.0.9
'@code-inspector/mako': 2.0.9
'@code-inspector/turbopack': 2.0.9
'@code-inspector/vite': 2.0.9
'@code-inspector/webpack': 2.0.9
chalk: 4.1.1
transitivePeerDependencies:
- '@anthropic-ai/claude-agent-sdk'
- '@openai/codex-sdk'
- '@opencode-ai/sdk'
- bufferutil
- supports-color
- utf-8-validate
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -5188,6 +5385,8 @@ snapshots:
dependencies:
is-obj: 2.0.0
dotenv@16.6.1: {}
dotenv@17.4.2: {}
drizzle-kit@1.0.0-rc.4:
@@ -5232,6 +5431,8 @@ snapshots:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@7.0.1: {}
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -5591,6 +5792,8 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@2.0.2: {}
esutils@2.0.3: {}
etag@1.8.1: {}
@@ -6225,6 +6428,11 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.20
launch-ide@1.4.10:
dependencies:
chalk: 4.1.1
dotenv: 16.6.1
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -6413,6 +6621,9 @@ snapshots:
- '@types/node'
- babel-plugin-macros
node-addon-api@7.1.1:
optional: true
node-exports-info@1.6.2:
dependencies:
array.prototype.flatmap: 1.3.3
@@ -6420,6 +6631,11 @@ snapshots:
object.entries: 1.1.9
semver: 6.3.1
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
optional: true
node-releases@2.0.56: {}
npm-run-path@4.0.1:
@@ -6632,6 +6848,13 @@ snapshots:
dependencies:
find-up: 3.0.0
portfinder@1.0.38:
dependencies:
async: 3.2.6
debug: 4.4.3
transitivePeerDependencies:
- supports-color
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.6:
@@ -7371,6 +7594,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.22.0: {}
wsl-utils@1.0.0:
dependencies:
is-wsl: 3.1.1
@@ -1,24 +0,0 @@
// 顶栏「添加磁力」按钮:打开添加磁力链接弹窗
"use client";
import { useState } from "react";
import { MagnetIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AddMagnetDialog } from "./add-magnet-dialog";
export const AddMagnetButton = () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="ghost" onClick={() => setOpen(true)}>
<MagnetIcon data-icon="inline-start" />
{/* 手机宽度下只显示图标,文字留给读屏 */}
<span className="sr-only sm:not-sr-only">添加磁力</span>
</Button>
<AddMagnetDialog open={open} onOpenChange={setOpen} />
</>
);
};
@@ -1,17 +1,10 @@
// 添加磁力链接弹窗:粘贴 magnet 后添加到 TorBox
// 添加磁力链接面板:粘贴 magnet 后添加到 TorBox
"use client";
import { useState } from "react";
import { CloudDownloadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {
Field,
FieldError,
@@ -26,15 +19,20 @@ import { isMagnetLink } from "@/utils/magnet";
const FORM_ID = "add-magnet-form";
type AddMagnetPanelProps = {
/** 添加成功后调用,用于关闭弹窗 */
onAdded: () => void;
};
/** 弹窗每次打开都会重新挂载,输入内容随之清空 */
const AddMagnetForm = ({ onAdded }: { onAdded: () => void }) => {
export const AddMagnetPanel = ({ onAdded }: AddMagnetPanelProps) => {
const [draft, setDraft] = useState("");
const { mutate, isPending } = useAddMagnet();
const magnet = draft.trim();
const invalid = magnet !== "" && !isMagnetLink(magnet);
return (
<>
<div className="flex flex-col gap-4">
<form
id={FORM_ID}
onSubmit={(event) => {
@@ -54,11 +52,10 @@ const AddMagnetForm = ({ onAdded }: { onAdded: () => void }) => {
value={draft}
onChange={(event) => setDraft(event.target.value)}
maxLength={MAX_MAGNET_LENGTH}
rows={4}
rows={6}
placeholder="magnet:?xt=urn:btih:..."
aria-invalid={invalid || undefined}
autoComplete="off"
autoFocus
spellCheck={false}
className="font-mono"
/>
@@ -67,10 +64,11 @@ const AddMagnetForm = ({ onAdded }: { onAdded: () => void }) => {
</Field>
</FieldGroup>
</form>
<DialogFooter>
<div className="flex sm:justify-end">
<Button
type="submit"
form={FORM_ID}
className="max-sm:w-full"
disabled={isPending || !magnet || invalid}
>
{isPending ? (
@@ -80,26 +78,7 @@ const AddMagnetForm = ({ onAdded }: { onAdded: () => void }) => {
)}
添加到 TorBox
</Button>
</DialogFooter>
</>
</div>
</div>
);
};
type AddMagnetDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const AddMagnetDialog = ({
open,
onOpenChange
}: AddMagnetDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>添加磁力链接</DialogTitle>
</DialogHeader>
<AddMagnetForm onAdded={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
@@ -1,26 +1,32 @@
// 顶栏搜索按钮:打开搜索弹窗,并在按钮上显示后台搜索的进度与完成提示
// 顶栏「添加资源」按钮:打开添加资源弹窗,并在按钮上显示后台搜索的进度与完成提示
"use client";
import { CheckIcon, SearchIcon, TriangleAlertIcon } from "lucide-react";
import { CheckIcon, PlusIcon, TriangleAlertIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { useSearchStore } from "@/stores/search-store";
import { SearchDialog } from "./search-dialog";
import { AddResourceDialog } from "./add-resource-dialog";
export const SearchButton = () => {
type AddResourceButtonProps = {
searchEnabled: boolean;
};
export const AddResourceButton = ({
searchEnabled
}: AddResourceButtonProps) => {
const status = useSearchStore((state) => state.status);
const total = useSearchStore((state) => state.total);
const completed = useSearchStore((state) => state.completed);
const unseen = useSearchStore((state) => state.unseen);
const setOpen = useSearchStore((state) => state.setOpen);
const searching = status === "searching";
const searching = searchEnabled && status === "searching";
// 完成/失败后一直保持到用户打开弹窗看过为止
const finished = unseen && status === "done";
const failed = unseen && status === "failed";
const finished = searchEnabled && unseen && status === "done";
const failed = searchEnabled && unseen && status === "failed";
return (
<>
@@ -36,7 +42,7 @@ export const SearchButton = () => {
) : failed ? (
<TriangleAlertIcon data-icon="inline-start" />
) : (
<SearchIcon data-icon="inline-start" />
<PlusIcon data-icon="inline-start" />
)}
{/* 手机宽度下只显示图标,文字留给读屏 */}
<span className="sr-only sm:not-sr-only">
@@ -46,7 +52,7 @@ export const SearchButton = () => {
? "搜索完成"
: failed
? "搜索失败"
: "搜索"}
: "添加资源"}
</span>
{searching && total > 0 && (
<span className="font-mono text-xs text-muted-foreground tabular-nums">
@@ -54,7 +60,7 @@ export const SearchButton = () => {
</span>
)}
</Button>
<SearchDialog />
<AddResourceDialog searchEnabled={searchEnabled} />
</>
);
};
@@ -0,0 +1,99 @@
// 添加资源弹窗:搜索资源与直接添加磁力链接合并在同一个弹窗的两个标签页里。
// 与视频弹窗一致:手机全屏、sm 起铺满顶栏以下,非模态,返回键先关闭弹窗
"use client";
import { MagnetIcon, SearchIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useInertMain } from "@/hooks/use-inert-main";
import { useSearchStore, type ResourceTab } from "@/stores/search-store";
import { AddMagnetPanel } from "./add-magnet-panel";
import { SearchPanel } from "./search-panel";
type AddResourceDialogProps = {
/** 未配置 Jackett 时没有搜索,弹窗只剩添加磁力 */
searchEnabled: boolean;
};
export const AddResourceDialog = ({
searchEnabled
}: AddResourceDialogProps) => {
const open = useSearchStore((state) => state.open);
const storedTab = useSearchStore((state) => state.tab);
const setOpen = useSearchStore((state) => state.setOpen);
const setTab = useSearchStore((state) => state.setTab);
const tab: ResourceTab = searchEnabled ? storedTab : "magnet";
// 手机返回键 / 返回手势先关闭弹窗,不离开页面;搜索在 store 里,关闭不会中断
useCloseOnBack(open, () => setOpen(false));
useInertMain(open);
return (
<Dialog
open={open}
onOpenChange={setOpen}
// 非模态:顶栏照常可用;点击弹窗外或焦点移出都不关闭,用关闭按钮、Esc 或返回键关闭
modal={false}
disablePointerDismissal
>
<DialogContent
// 不变暗、不模糊,顶栏保持清晰可见
showOverlay={false}
// 手机全屏;sm 起从顶栏下方开始(h-14 + 1px 边框),顶栏露在外面
className="inset-0 flex max-w-none translate-x-0 translate-y-0 flex-col rounded-none p-0 ring-0 sm:top-14.25 sm:max-w-none"
>
{/* 与顶栏、资源列表同样限宽 1920px 居中;右侧留出关闭按钮的位置 */}
<div className="mx-auto flex min-h-0 w-full max-w-[1920px] flex-1 flex-col gap-4 p-4 md:px-6 md:py-6">
<DialogHeader className="pr-10">
<DialogTitle className="sm:text-xl">
{searchEnabled ? "添加资源" : "添加磁力链接"}
</DialogTitle>
</DialogHeader>
{searchEnabled ? (
<Tabs
value={tab}
onValueChange={(value: ResourceTab) => setTab(value)}
className="min-h-0 flex-1"
>
<TabsList className="w-full sm:w-fit">
<TabsTrigger value="search">
<SearchIcon />
搜索资源
</TabsTrigger>
<TabsTrigger value="magnet">
<MagnetIcon />
磁力链接
</TabsTrigger>
</TabsList>
{/* keepMounted:切换标签页不丢已输入的关键词与磁力链接;hidden 时 display:flex 会盖过 hidden 属性,要显式隐藏 */}
<TabsContent
value="search"
keepMounted
className="flex min-h-0 flex-1 flex-col data-hidden:hidden"
>
<SearchPanel />
</TabsContent>
<TabsContent
value="magnet"
keepMounted
className="data-hidden:hidden"
>
<AddMagnetPanel onAdded={() => setOpen(false)} />
</TabsContent>
</Tabs>
) : (
<AddMagnetPanel onAdded={() => setOpen(false)} />
)}
</div>
</DialogContent>
</Dialog>
);
};
+3 -5
View File
@@ -2,18 +2,17 @@
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { AddMagnetButton } from "./add-magnet-button";
import { AddResourceButton } from "./add-resource-button";
import { HeaderBrand } from "./header-brand";
import { LogoutButton } from "./logout-button";
import { MobileMenu } from "./mobile-menu";
import { PlaylistMenu } from "./playlist-menu";
import { SearchButton } from "./search-button";
type AppHeaderProps = {
token: string;
/** 演示模式:未配置 TORBOX_API_KEY,资源库为模拟数据 */
mock: boolean;
/** 已配置 Jackett(或演示模式);否则不显示搜索入口 */
/** 已配置 Jackett(或演示模式);否则添加资源弹窗里没有搜索页 */
searchEnabled: boolean;
};
@@ -32,8 +31,7 @@ export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
</Badge>
)}
<div className="ml-auto flex items-center gap-1">
<AddMagnetButton />
{searchEnabled && <SearchButton />}
<AddResourceButton searchEnabled={searchEnabled} />
{/* 手机宽度下播放列表与退出登录收进右上角菜单 */}
<div className="hidden md:contents">
<PlaylistMenu token={token} />
+39 -28
View File
@@ -6,6 +6,7 @@ import { ChevronRightIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SOURCE_LABELS } from "@/constants/torbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import type { LibraryItemView } from "@/hooks/use-library";
import { formatBytes, formatCount } from "@/utils/format";
@@ -47,32 +48,42 @@ const ItemSummary = ({ item }: { item: LibraryItemView }) => {
};
/** 资源分组标题行:虚拟列表中标题与卡片行相互独立,折叠状态由外部控制 */
export const ItemHeader = ({ item, expanded, onToggle }: ItemHeaderProps) => (
<div className="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
aria-label={expanded ? "收起" : "展开"}
aria-expanded={expanded}
data-open={expanded || undefined}
className="data-open:[&_svg]:rotate-90"
onClick={onToggle}
export const ItemHeader = ({ item, expanded, onToggle }: ItemHeaderProps) => {
// 与 Tailwind sm 断点一致;窄屏整行可点击展开收起,按钮点击冒泡到整行,避免重复触发
const mobile = useMediaQuery("(width < 40rem)");
return (
<div
className="flex min-w-0 items-center gap-2"
onClick={mobile ? onToggle : undefined}
>
<ChevronRightIcon className="transition-transform" />
</Button>
{/* 窄屏只留来源色点,把空间让给标题 */}
<Badge variant="outline" title={SOURCE_LABELS[item.source]}>
<SourceDot source={item.source} />
<span className="sr-only sm:not-sr-only">
{SOURCE_LABELS[item.source]}
</span>
</Badge>
{item.isNew && <NewBadge createdAt={item.createdAt} />}
<h2 className="min-w-0 flex-1 truncate font-medium" title={item.name}>
{item.name}
</h2>
<ItemDownloadStatus item={item} />
<ExpiryBadge item={item} />
<ItemSummary item={item} />
</div>
);
<Button
variant="ghost"
size="icon-sm"
aria-label={expanded ? "收起" : "展开"}
aria-expanded={expanded}
data-open={expanded || undefined}
className="data-open:[&_svg]:rotate-90"
onClick={mobile ? undefined : onToggle}
>
<ChevronRightIcon className="transition-transform" />
</Button>
{/* 窄屏只留来源色点,把空间让给标题 */}
<Badge
variant="outline"
title={SOURCE_LABELS[item.source]}
className="max-sm:hidden"
>
<SourceDot source={item.source} />
<span className="not-sr-only">{SOURCE_LABELS[item.source]}</span>
</Badge>
{item.isNew && <NewBadge createdAt={item.createdAt} />}
<h2 className="min-w-0 flex-1 truncate font-medium" title={item.name}>
{item.name}
</h2>
<ItemDownloadStatus item={item} />
<ExpiryBadge item={item} />
<ItemSummary item={item} />
</div>
);
};
@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import type { LightboxImage } from "@/components/image-lightbox";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import type { LibraryItemView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
import { buildStreamUrl } from "@/utils/media";
@@ -58,17 +59,22 @@ export const LibraryImagePreview = ({
? files.findIndex((file) => file.id === target.fileId)
: -1;
// 手机返回键 / 返回手势先关闭预览,不离开页面
const open = index >= 0;
const close = () => onTargetChange(null);
useCloseOnBack(open, close);
// 首次打开后保持挂载,关闭时才有退场动画
const [activated, setActivated] = useState(false);
if (index >= 0 && !activated) setActivated(true);
if (open && !activated) setActivated(true);
if (!activated) return null;
return (
<ImageLightbox
images={images}
index={Math.max(index, 0)}
open={index >= 0}
onClose={() => onTargetChange(null)}
open={open}
onClose={close}
onIndexChange={(next) => {
// 打开时也会回调一次当前序号,同一张不重复更新
const file = files[next];
+4 -2
View File
@@ -525,10 +525,12 @@ const VirtualRows = ({
return (
<>
{/* 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动;
grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器 */}
grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器。
overflow-y-clip:收起、筛选后列表变短,隐藏的准星仍停在旧位置,transform 会撑大
滚动容器的可滚动高度,列表下方出现一大段空白;clip 不创建滚动容器,不影响 sticky */}
<div
ref={containerRef}
className="grid grid-cols-1 *:col-start-1 *:row-start-1"
className="grid grid-cols-1 overflow-y-clip *:col-start-1 *:row-start-1"
>
{/* 可见行处于正常文档流,未渲染部分用占位块撑出;每个资源分组包一层容器,作为标题吸顶的边界 */}
<div className="flex flex-col">
@@ -21,6 +21,7 @@ import {
} from "@/components/video-player-loader";
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useInertMain } from "@/hooks/use-inert-main";
import { useCountdown } from "@/hooks/use-countdown";
import { useLimitRetry, type LimitWait } from "@/hooks/use-limit-retry";
import type { LibraryItemView } from "@/hooks/use-library";
@@ -173,14 +174,7 @@ export const LibraryVideoDialog = ({
// 手机返回键 / 返回手势先关闭播放,不离开页面
useCloseOnBack(open, onClose);
// 非模态弹窗不会让页面其余部分失效:把被它盖住的内容区(顶栏以下的 <main>)设为 inert,
// Tab 与「/」聚焦搜索等快捷键不会落到看不见的资源列表上;顶栏不受影响
useEffect(() => {
if (!open) return;
const main = document.querySelector("main");
main?.setAttribute("inert", "");
return () => main?.removeAttribute("inert");
}, [open]);
useInertMain(open);
// 打开期间登记关闭方法:点顶栏品牌标识也能关闭,回到资源库
const setClose = useVideoDialogStore((state) => state.setClose);
-265
View File
@@ -1,265 +0,0 @@
// 搜索弹窗:关键词、进度、失败站点与结果列表;关闭弹窗不会中断搜索(状态在 search-store)
"use client";
import { useState } from "react";
import {
ChevronDownIcon,
ListFilterIcon,
SearchIcon,
TriangleAlertIcon
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group";
import {
Progress,
ProgressLabel,
ProgressValue
} from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { MAX_QUERY_LENGTH } from "@/constants/search";
import { useSearchResults } from "@/hooks/use-search-results";
import { cn } from "@/lib/utils";
import { useSearchStore, type SearchStatus } from "@/stores/search-store";
import { useSearchViewStore } from "@/stores/search-view-store";
import { SearchResultList } from "./search-result-list";
import { SearchResultToolbar } from "./search-result-toolbar";
/** 结果区固定高度:弹窗不随结果数量忽高忽低 */
const RESULT_HEIGHT = "h-[min(40rem,60dvh)]";
const progressLabel = (status: SearchStatus, resultCount: number) => {
switch (status) {
case "searching":
return `正在搜索 · 已有 ${resultCount} 条结果`;
case "done":
return `搜索完成 · 共 ${resultCount} 条结果`;
case "cancelled":
return `已取消 · 已有 ${resultCount} 条结果`;
default:
return `搜索失败 · 已有 ${resultCount} 条结果`;
}
};
const SearchForm = () => {
const status = useSearchStore((state) => state.status);
const start = useSearchStore((state) => state.start);
const cancel = useSearchStore((state) => state.cancel);
// 弹窗每次打开都会重新挂载,从最近一次搜索的关键词继续
const [draft, setDraft] = useState(() => useSearchStore.getState().query);
const keyword = draft.trim();
return (
<form
onSubmit={(event) => {
event.preventDefault();
if (!keyword) return;
// 新结果与旧的筛选词无关,清掉避免一上来就是空列表
useSearchViewStore.getState().clearFilters();
void start(keyword);
}}
>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={draft}
onChange={(event) => setDraft(event.target.value)}
maxLength={MAX_QUERY_LENGTH}
placeholder="输入关键词,如片名"
aria-label="搜索关键词"
autoComplete="off"
/>
<InputGroupAddon align="inline-end">
{status === "searching" ? (
<InputGroupButton
variant="outline"
onClick={(event) => {
// 取消与「搜索」是同一个 DOM 按钮,cancel 改状态后会先重渲染成 submit,
// 不阻止默认行为的话这次点击会紧接着提交表单,变成重新搜索
event.preventDefault();
cancel();
}}
>
取消
</InputGroupButton>
) : (
<InputGroupButton
type="submit"
variant="default"
disabled={!keyword}
>
搜索
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</form>
);
};
const SearchProgress = () => {
const status = useSearchStore((state) => state.status);
const total = useSearchStore((state) => state.total);
const completed = useSearchStore((state) => state.completed);
const resultCount = useSearchStore((state) => state.results.length);
const failures = useSearchStore((state) => state.failures);
const error = useSearchStore((state) => state.error);
if (status === "idle") return null;
return (
<div className="flex flex-col gap-3">
<Progress
// 收到 start 事件前不知道总数,显示为不确定进度
value={total ? (completed / total) * 100 : null}
aria-label="搜索进度"
>
<ProgressLabel>{progressLabel(status, resultCount)}</ProgressLabel>
<ProgressValue>
{() => (total ? `${completed} / ${total} 个站点` : "")}
</ProgressValue>
</Progress>
{status === "failed" && error && (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{failures.length > 0 && (
<Collapsible>
<CollapsibleTrigger className="group flex items-center gap-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground">
{failures.length} 个站点没有返回结果
<ChevronDownIcon className="size-3.5 transition-transform group-aria-expanded:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 flex max-h-24 flex-col gap-1 overflow-y-auto font-mono text-xs text-muted-foreground">
{failures.map((failure) => (
<li
key={failure.indexerId}
className="truncate"
title={failure.error}
>
{failure.indexerName} — {failure.error}
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
)}
</div>
);
};
const SearchResults = () => {
const status = useSearchStore((state) => state.status);
const results = useSearchStore((state) => state.results);
const sortKey = useSearchViewStore((state) => state.sortKey);
const order = useSearchViewStore((state) => state.order);
const cachedOnly = useSearchViewStore((state) => state.cachedOnly);
const clearFilters = useSearchViewStore((state) => state.clearFilters);
const { items, terms } = useSearchResults(results);
if (results.length > 0)
return (
<div className="flex flex-col gap-2">
<SearchResultToolbar total={results.length} shown={items.length} />
{items.length > 0 ? (
<SearchResultList
items={items}
terms={terms}
resetScrollKey={`${sortKey}:${order}:${terms.join(" ")}:${cachedOnly}`}
className={RESULT_HEIGHT}
/>
) : (
<Empty className={RESULT_HEIGHT}>
<EmptyHeader>
<EmptyMedia variant="icon">
<ListFilterIcon />
</EmptyMedia>
<EmptyTitle>没有匹配的结果</EmptyTitle>
<EmptyDescription>
共 {results.length} 条结果中没有符合当前筛选条件的
</EmptyDescription>
</EmptyHeader>
<Button variant="outline" size="sm" onClick={clearFilters}>
清除筛选
</Button>
</Empty>
)}
</div>
);
if (status === "searching")
return (
<div
className={cn("flex flex-col gap-2", RESULT_HEIGHT)}
aria-busy="true"
aria-label="正在等待站点返回"
>
{Array.from({ length: 10 }, (_, index) => (
<Skeleton key={index} className="h-14 shrink-0" />
))}
</div>
);
const idle = status === "idle";
return (
<Empty className={RESULT_HEIGHT}>
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchIcon />
</EmptyMedia>
<EmptyTitle>{idle ? "输入关键词开始搜索" : "没有找到结果"}</EmptyTitle>
<EmptyDescription>{idle ? "" : "换个关键词试试"}</EmptyDescription>
</EmptyHeader>
</Empty>
);
};
export const SearchDialog = () => {
const open = useSearchStore((state) => state.open);
const setOpen = useSearchStore((state) => state.setOpen);
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* grid-cols-1:轨道可被压缩,内容再宽也不会把弹窗撑出屏幕 */}
<DialogContent className="grid-cols-1 sm:max-w-5xl">
<DialogHeader>
<DialogTitle>搜索资源</DialogTitle>
</DialogHeader>
<SearchForm />
<SearchProgress />
<SearchResults />
</DialogContent>
</Dialog>
);
};
+312
View File
@@ -0,0 +1,312 @@
// 搜索面板:关键词、进度、失败站点与结果列表;关闭弹窗不会中断搜索(状态在 search-store)
"use client";
import { useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ListFilterIcon, SearchIcon, TriangleAlertIcon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from "@/components/ui/input-group";
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverTrigger
} from "@/components/ui/popover";
import {
Progress,
ProgressLabel,
ProgressValue
} from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { TOOLBAR_COLLAPSE } from "@/constants/motion";
import { MAX_QUERY_LENGTH } from "@/constants/search";
import { useElementHeight } from "@/hooks/use-element-height";
import { useHideOnScroll } from "@/hooks/use-hide-on-scroll";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useSearchResults } from "@/hooks/use-search-results";
import { cn } from "@/lib/utils";
import { useSearchStore, type SearchStatus } from "@/stores/search-store";
import { useSearchViewStore } from "@/stores/search-view-store";
import { SearchResultList } from "./search-result-list";
import { SearchResultToolbar } from "./search-result-toolbar";
/** 结果区占满弹窗剩余高度,不随结果数量忽高忽低 */
const RESULT_AREA = "min-h-0 flex-1";
const progressLabel = (status: SearchStatus, resultCount: number) => {
switch (status) {
case "searching":
return `正在搜索 · 已有 ${resultCount} 条结果`;
case "done":
return `搜索完成 · 共 ${resultCount} 条结果`;
case "cancelled":
return `已取消 · 已有 ${resultCount} 条结果`;
default:
return `搜索失败 · 已有 ${resultCount} 条结果`;
}
};
const SearchForm = () => {
const status = useSearchStore((state) => state.status);
const start = useSearchStore((state) => state.start);
const cancel = useSearchStore((state) => state.cancel);
// 弹窗每次打开都会重新挂载,从最近一次搜索的关键词继续
const [draft, setDraft] = useState(() => useSearchStore.getState().query);
const keyword = draft.trim();
return (
<form
onSubmit={(event) => {
event.preventDefault();
if (!keyword) return;
// 新结果与旧的筛选词无关,清掉避免一上来就是空列表
useSearchViewStore.getState().clearFilters();
void start(keyword);
}}
>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={draft}
onChange={(event) => setDraft(event.target.value)}
maxLength={MAX_QUERY_LENGTH}
placeholder="输入关键词,如片名"
aria-label="搜索关键词"
autoComplete="off"
/>
<InputGroupAddon align="inline-end">
{status === "searching" ? (
<InputGroupButton
variant="outline"
onClick={(event) => {
// 取消与「搜索」是同一个 DOM 按钮,cancel 改状态后会先重渲染成 submit,
// 不阻止默认行为的话这次点击会紧接着提交表单,变成重新搜索
event.preventDefault();
cancel();
}}
>
取消
</InputGroupButton>
) : (
<InputGroupButton
type="submit"
variant="default"
disabled={!keyword}
>
搜索
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</form>
);
};
/** 部分站点没有返回结果:图标按钮 + 弹出详情,不占独立一行 */
const FailureInfo = () => {
const failures = useSearchStore((state) => state.failures);
if (failures.length === 0) return null;
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label={`${failures.length} 个站点没有返回结果`}
/>
}
>
<TriangleAlertIcon />
</PopoverTrigger>
<PopoverContent align="end" className="w-80 max-w-[calc(100vw-2rem)]">
<PopoverHeader>
<PopoverTitle>{failures.length} 个站点没有返回结果</PopoverTitle>
</PopoverHeader>
<ul className="flex max-h-60 flex-col gap-1.5 overflow-y-auto font-mono text-xs text-muted-foreground">
{failures.map((failure) => (
<li key={failure.indexerId} className="wrap-anywhere">
{failure.indexerName} — {failure.error}
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
};
const SearchProgress = () => {
const status = useSearchStore((state) => state.status);
const total = useSearchStore((state) => state.total);
const completed = useSearchStore((state) => state.completed);
const resultCount = useSearchStore((state) => state.results.length);
const error = useSearchStore((state) => state.error);
if (status === "idle") return null;
return (
<div className="flex flex-col gap-3">
<Progress
// 收到 start 事件前不知道总数,显示为不确定进度
value={total ? (completed / total) * 100 : null}
aria-label="搜索进度"
className="items-center"
>
<ProgressLabel>{progressLabel(status, resultCount)}</ProgressLabel>
<ProgressValue>
{() => (total ? `${completed} / ${total} 个站点` : "")}
</ProgressValue>
<FailureInfo />
</Progress>
{status === "failed" && error && (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
};
/**
* 进度与结果区。手机宽度下进度和筛选工具栏浮在列表上方(与资源库一致):
* 向下浏览时上移收起,向上浏览时滑回,列表不重新布局;正在输入筛选词时保持展开。
* 宽屏下它们是普通的纵向排列
*/
const SearchBody = () => {
const status = useSearchStore((state) => state.status);
const results = useSearchStore((state) => state.results);
const sortKey = useSearchViewStore((state) => state.sortKey);
const order = useSearchViewStore((state) => state.order);
const cachedOnly = useSearchViewStore((state) => state.cachedOnly);
const clearFilters = useSearchViewStore((state) => state.clearFilters);
const { items, terms } = useSearchResults(results);
const scrollRef = useRef<HTMLDivElement>(null);
const mobile = useMediaQuery("(width < 48rem)");
const [filterFocused, setFilterFocused] = useState(false);
// 列表在有结果后才挂载;hook 只在启用时读取滚动容器,所以列表不存在时不启用
const listMounted = items.length > 0;
const hidden = useHideOnScroll(scrollRef, {
enabled: mobile && !filterFocused && listMounted,
threshold: TOOLBAR_COLLAPSE.threshold,
topOffset: TOOLBAR_COLLAPSE.topOffset
});
const reduced = useReducedMotion();
const [headRef, headHeight] = useElementHeight<HTMLDivElement>();
/** 浮层完全展开时盖住的列表顶部高度;宽屏浮层不盖住列表,为 0 */
const inset = mobile ? headHeight : 0;
const hasResults = results.length > 0;
const idle = status === "idle";
let content: React.ReactNode;
if (hasResults && items.length > 0)
content = (
<SearchResultList
items={items}
terms={terms}
resetScrollKey={`${sortKey}:${order}:${terms.join(" ")}:${cachedOnly}`}
scrollRef={scrollRef}
topInset={inset}
className={RESULT_AREA}
/>
);
else if (hasResults)
content = (
<Empty className={RESULT_AREA} style={{ marginTop: inset }}>
<EmptyHeader>
<EmptyMedia variant="icon">
<ListFilterIcon />
</EmptyMedia>
<EmptyTitle>没有匹配的结果</EmptyTitle>
<EmptyDescription>
共 {results.length} 条结果中没有符合当前筛选条件的
</EmptyDescription>
</EmptyHeader>
<Button variant="outline" size="sm" onClick={clearFilters}>
清除筛选
</Button>
</Empty>
);
else if (status === "searching")
content = (
<div
className={cn("flex flex-col gap-2 overflow-hidden", RESULT_AREA)}
style={{ marginTop: inset }}
aria-busy="true"
aria-label="正在等待站点返回"
>
{Array.from({ length: 10 }, (_, index) => (
<Skeleton key={index} className="h-14 shrink-0" />
))}
</div>
);
else
content = (
<Empty className={RESULT_AREA} style={{ marginTop: inset }}>
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchIcon />
</EmptyMedia>
<EmptyTitle>
{idle ? "输入关键词开始搜索" : "没有找到结果"}
</EmptyTitle>
<EmptyDescription>{idle ? "" : "换个关键词试试"}</EmptyDescription>
</EmptyHeader>
</Empty>
);
return (
// 手机宽度下浮层与内容叠放在同一格(单格 grid),浮层在上层;
// 收起时用 transform 上移并被这一层裁掉,看起来滑进搜索框下方
<div className="flex min-h-0 flex-1 flex-col max-md:grid max-md:grid-cols-1 max-md:grid-rows-1 max-md:overflow-hidden max-md:*:col-start-1 max-md:*:row-start-1">
<motion.div
ref={headRef}
className="flex shrink-0 flex-col gap-3 bg-popover pb-2 max-md:z-20 max-md:self-start"
initial={false}
animate={{ y: hidden ? -headHeight : 0 }}
transition={reduced ? { duration: 0 } : TOOLBAR_COLLAPSE.transition}
inert={hidden}
>
<SearchProgress />
{hasResults && (
<SearchResultToolbar
total={results.length}
shown={items.length}
onFilterFocusChange={setFilterFocused}
/>
)}
</motion.div>
{content}
</div>
);
};
export const SearchPanel = () => (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<SearchForm />
<SearchBody />
</div>
);
@@ -1,7 +1,7 @@
// 搜索结果列表(虚拟滚动):结果可达数千条,行高固定,只渲染可见行
"use client";
import { memo, useState } from "react";
import { memo, useState, type RefObject } from "react";
import {
ArrowDownIcon,
ArrowUpIcon,
@@ -211,6 +211,10 @@ type SearchResultListProps = {
terms: readonly string[];
/** 筛选或排序条件变化时滚回顶部 */
resetScrollKey: string;
/** 由父组件持有滚动容器,用来监听滚动方向 */
scrollRef?: RefObject<HTMLDivElement | null>;
/** 列表顶部给浮在上方的工具栏留的高度(px) */
topInset?: number;
className?: string;
};
@@ -218,6 +222,8 @@ export const SearchResultList = ({
items,
terms,
resetScrollKey,
scrollRef,
topInset,
className
}: SearchResultListProps) => {
// 相对日期以列表挂载时刻为「现在」;放在这里是为了不让每一行各自取时间
@@ -232,6 +238,8 @@ export const SearchResultList = ({
<ResultRow result={result} terms={terms} now={now} />
)}
resetScrollKey={resetScrollKey}
scrollRef={scrollRef}
topInset={topInset}
className={className}
/>
);
@@ -56,7 +56,7 @@ const SortMenu = () => {
render={
<Button
variant="outline"
className="shrink-0"
className="shrink-0 max-sm:ml-auto"
aria-label={`排序:${label},${orderLabel}`}
/>
}
@@ -115,11 +115,14 @@ type SearchResultToolbarProps = {
total: number;
/** 筛选后剩余的条数 */
shown: number;
/** 筛选框获得 / 失去焦点:手机宽度下输入时工具栏保持展开 */
onFilterFocusChange?: (focused: boolean) => void;
};
export const SearchResultToolbar = ({
total,
shown
shown,
onFilterFocusChange
}: SearchResultToolbarProps) => {
const keyword = useSearchViewStore((state) => state.keyword);
const setKeyword = useSearchViewStore((state) => state.setKeyword);
@@ -127,8 +130,8 @@ export const SearchResultToolbar = ({
const setCachedOnly = useSearchViewStore((state) => state.setCachedOnly);
return (
<div className="flex items-center gap-2">
<InputGroup>
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="max-sm:basis-full sm:min-w-0 sm:flex-1">
<InputGroupAddon>
<ListFilterIcon />
</InputGroupAddon>
@@ -138,6 +141,8 @@ export const SearchResultToolbar = ({
placeholder="在结果中筛选,空格分隔多个词"
aria-label="在结果中筛选"
autoComplete="off"
onFocus={() => onFilterFocusChange?.(true)}
onBlur={() => onFilterFocusChange?.(false)}
/>
{(keyword || cachedOnly) && (
<InputGroupAddon align="inline-end">
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+14 -5
View File
@@ -4,7 +4,7 @@
// 不使用定位:上下各放一个占位块撑出未渲染部分的高度,可见行保持在正常文档流里。
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils";
@@ -19,6 +19,10 @@ type VirtualListProps<T> = {
overscan?: number;
/** 值变化时滚回顶部,如筛选、排序条件改变;流式追加数据不应改变它 */
resetScrollKey?: string;
/** 由外部持有滚动容器(如要监听滚动方向)时传入;不传则组件内部持有 */
scrollRef?: RefObject<HTMLDivElement | null>;
/** 列表顶部留白(px),用于不占布局、浮在列表上方的工具栏;留白随内容一起滚走 */
topInset?: number;
className?: string;
};
@@ -29,9 +33,12 @@ export const VirtualList = <T,>({
renderItem,
overscan = 8,
resetScrollKey,
scrollRef: externalRef,
topInset = 0,
className
}: VirtualListProps<T>) => {
const scrollRef = useRef<HTMLDivElement>(null);
const internalRef = useRef<HTMLDivElement>(null);
const scrollRef = externalRef ?? internalRef;
// eslint-disable-next-line react-hooks/incompatible-library -- 项目未启用 React Compiler;虚拟器每次渲染都需读取最新滚动状态
const virtualizer = useVirtualizer({
@@ -39,15 +46,17 @@ export const VirtualList = <T,>({
getScrollElement: () => scrollRef.current,
getItemKey: (index) => getItemKey(items[index]),
estimateSize: () => estimateSize,
overscan
overscan,
scrollMargin: topInset
});
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
}, [resetScrollKey]);
}, [scrollRef, resetScrollKey]);
const rows = virtualizer.getVirtualItems();
const top = rows[0]?.start ?? 0;
// 行的 start 已包含 scrollMargin,顶部占位块同时撑出了留白
const top = rows[0]?.start ?? topInset;
const bottom = virtualizer.getTotalSize() - (rows.at(-1)?.end ?? 0);
return (
+1 -1
View File
@@ -23,6 +23,6 @@ export const SEARCH_SORT_OPTIONS: {
{ value: "name", label: "名称", defaultOrder: "asc" },
{ value: "seeders", label: "做种者", defaultOrder: "desc" },
{ value: "leechers", label: "下载者", defaultOrder: "desc" },
{ value: "size", label: "文件大小", defaultOrder: "desc" },
{ value: "size", label: "大小", defaultOrder: "desc" },
{ value: "date", label: "日期", defaultOrder: "desc" }
];
+15
View File
@@ -0,0 +1,15 @@
// 非模态全屏弹层盖住内容区时,让内容区失效的 hook
import { useEffect } from "react";
/**
* 非模态弹窗不会让页面其余部分失效:把被它盖住的内容区(顶栏以下的 <main>)设为 inert,
* Tab 与「/」聚焦搜索等快捷键不会落到看不见的资源列表上;顶栏不受影响
*/
export const useInertMain = (open: boolean) => {
useEffect(() => {
if (!open) return;
const main = document.querySelector("main");
main?.setAttribute("inert", "");
return () => main?.removeAttribute("inert");
}, [open]);
};
+19 -9
View File
@@ -9,11 +9,7 @@ import { streamSearch } from "@/services/search";
import type { SearchEvent, SearchResult } from "@/types/search";
export type SearchStatus =
| "idle"
| "searching"
| "done"
| "cancelled"
| "failed";
"idle" | "searching" | "done" | "cancelled" | "failed";
export type SearchFailure = {
indexerId: string;
@@ -21,6 +17,9 @@ export type SearchFailure = {
error: string;
};
/** 弹窗里的两个标签页:搜索资源、直接添加磁力链接 */
export type ResourceTab = "search" | "magnet";
/** 结果加入 TorBox 的进度;没有记录表示还没添加 */
export type TorrentStatus = "adding" | "added";
@@ -42,9 +41,11 @@ type SearchState = {
/** 按结果 id 记录;不随新搜索清空,同一个种子在弹窗里不会被重复添加 */
torrentStatus: Record<string, TorrentStatus>;
open: boolean;
/** 搜索在弹窗关闭期间结束,用户还没看过结果 */
tab: ResourceTab;
/** 搜索在用户看不到结果时(弹窗关闭或停在磁力标签页)结束,还没看过结果 */
unseen: boolean;
setOpen: (open: boolean) => void;
setTab: (tab: ResourceTab) => void;
/** 记录结果加入 TorBox 的进度;不传 status 表示清除(添加失败后可重试) */
setTorrentStatus: (id: string, status?: TorrentStatus) => void;
start: (query: string) => Promise<void>;
@@ -76,9 +77,9 @@ const initialTask: Pick<
let controller: AbortController | null = null;
export const useSearchStore = create<SearchState>()((set, get) => {
/** 结束一次搜索;弹窗已关闭时用 toast 与按钮高亮告知 */
/** 结束一次搜索;用户看不到结果时用 toast 与按钮高亮告知 */
const settle = (patch: Partial<SearchState>, notify: () => void) => {
const hidden = !get().open;
const hidden = !get().open || get().tab !== "search";
set({ ...patch, unseen: hidden });
if (hidden) notify();
};
@@ -125,9 +126,18 @@ export const useSearchStore = create<SearchState>()((set, get) => {
...initialTask,
torrentStatus: {},
open: false,
tab: "search",
unseen: false,
setOpen: (open) => set(open ? { open, unseen: false } : { open }),
// 有没看过的搜索结果时,打开弹窗直接停在搜索页
setOpen: (open) =>
set((state) =>
open
? { open, unseen: false, tab: state.unseen ? "search" : state.tab }
: { open }
),
setTab: (tab) => set(tab === "search" ? { tab, unseen: false } : { tab }),
setTorrentStatus: (id, status) =>
set((state) => {