+6
-1
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+225
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
+13
-34
@@ -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>
|
||||
);
|
||||
+16
-10
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" }
|
||||
];
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user