chore: merge existing main history

This commit is contained in:
liaibo
2025-11-23 10:57:55 +08:00
132 changed files with 19696 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+78
View File
@@ -0,0 +1,78 @@
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\node_modules\.pnpm\next@16.0.3_@babel+core@7.2_a6e7fe7b2107bfd5c9e45031e322c5c3\node_modules\next\dist\compiled\next-server\app-page-turbo.runtime.dev.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__c5e4d87b._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
ReferenceError: window is not defined
at <unknown> (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js:421:9)
at Se (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js:419:354)
at BlockNoteEditor (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__c5e4d87b._.js:87:315) {
digest: '2629247923'
}
+60
View File
@@ -0,0 +1,60 @@
> wolai-frontend@0.1.0 dev F:\SOFT\MNOTE\wolai-frontend
> next dev "--port" "3001"
▲ Next.js 16.0.3 (Turbopack)
- Local: http://localhost:3001
- Network: http://192.168.121.1:3001
- Environments: .env.local
✓ Starting...
✓ Ready in 729ms
GET /login 200 in 2.3s (compile: 2.1s, render: 265ms)
GET / 200 in 613ms (compile: 365ms, render: 248ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 3.4s (compile: 2.9s, render: 473ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 442ms (compile: 26ms, render: 415ms)
GET /documents/1f256e20-0ef4-4090-9bec-954fcd799a4c 200 in 245ms (compile: 25ms, render: 220ms)
POST /api/documents/create 200 in 424ms (compile: 311ms, render: 113ms)
GET /documents/880f0a7a-e894-465b-a64d-bea1afa3da48 200 in 62ms (compile: 27ms, render: 34ms)
GET /documents/1f256e20-0ef4-4090-9bec-954fcd799a4c 200 in 68ms (compile: 22ms, render: 47ms)
GET /documents/e8b15472-3665-425e-b945-0de6bc9a15fb 200 in 56ms (compile: 22ms, render: 35ms)
GET /documents/880f0a7a-e894-465b-a64d-bea1afa3da48 200 in 56ms (compile: 22ms, render: 34ms)
GET / 307 in 297ms (compile: 2ms, render: 295ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 222ms (compile: 23ms, render: 199ms)
GET / 307 in 102ms (compile: 3ms, render: 99ms)
GET /login 200 in 53ms (compile: 3ms, render: 49ms)
GET / 200 in 20ms (compile: 3ms, render: 17ms)
GET /login 200 in 35ms (compile: 4ms, render: 31ms)
GET /login 200 in 18ms (compile: 2ms, render: 15ms)
GET / 200 in 20ms (compile: 3ms, render: 17ms)
GET /login 200 in 34ms (compile: 3ms, render: 31ms)
GET /login 200 in 18ms (compile: 2ms, render: 16ms)
GET / 307 in 108ms (compile: 3ms, render: 105ms)
GET /login 200 in 57ms (compile: 5ms, render: 52ms)
GET / 307 in 193ms (compile: 3ms, render: 190ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 125ms (compile: 23ms, render: 102ms)
GET / 200 in 27ms (compile: 4ms, render: 23ms)
GET /login 200 in 34ms (compile: 3ms, render: 31ms)
GET /login 200 in 19ms (compile: 3ms, render: 16ms)
GET / 200 in 19ms (compile: 3ms, render: 16ms)
GET /login 200 in 39ms (compile: 3ms, render: 36ms)
GET /login 200 in 19ms (compile: 3ms, render: 16ms)
GET / 200 in 20ms (compile: 3ms, render: 18ms)
GET /login 200 in 40ms (compile: 9ms, render: 31ms)
GET /login 200 in 19ms (compile: 2ms, render: 17ms)
GET / 200 in 27ms (compile: 5ms, render: 22ms)
GET /login 200 in 41ms (compile: 3ms, render: 37ms)
GET /login 200 in 21ms (compile: 2ms, render: 18ms)
GET / 200 in 23ms (compile: 3ms, render: 20ms)
GET /login 200 in 40ms (compile: 3ms, render: 37ms)
GET /login 200 in 22ms (compile: 3ms, render: 19ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 307 in 181ms (compile: 29ms, render: 152ms)
GET /login 200 in 60ms (compile: 3ms, render: 57ms)
✓ Compiled in 90ms
GET /login 200 in 235ms (compile: 93ms, render: 142ms)
GET /login 200 in 42ms (compile: 3ms, render: 39ms)
GET / 200 in 175ms (compile: 93ms, render: 82ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 313ms (compile: 125ms, render: 188ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 67ms (compile: 4ms, render: 64ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 235ms (compile: 118ms, render: 117ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 611ms (compile: 24ms, render: 587ms)
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+1
View File
@@ -0,0 +1 @@
^C
+11
View File
@@ -0,0 +1,11 @@
> wolai-frontend@0.1.0 dev F:\SOFT\MNOTE\wolai-frontend
> next dev "--port" "3001"
▲ Next.js 16.0.3 (Turbopack)
- Local: http://localhost:3001
- Network: http://192.168.121.1:3001
- Environments: .env.local
✓ Starting...
✓ Ready in 876ms
+12
View File
@@ -0,0 +1,12 @@
Failed to start server
F:\SOFT\MNOTE\wolai-frontend\node_modules\.pnpm\next@16.0.3_@babel+core@7.2_a6e7fe7b2107bfd5c9e45031e322c5c3\node_modules\next\dist\server\lib\start-server.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\node_modules\.pnpm\next@16.0.3_@babel+core@7.2_a6e7fe7b2107bfd5c9e45031e322c5c3\node_modules\next\dist\trace\trace.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: listen EADDRINUSE: address already in use :::3000
at <unknown> (Error: listen EADDRINUSE: address already in use :::3000)
at new Promise (<anonymous>) {
code: 'EADDRINUSE',
errno: -4091,
syscall: 'listen',
address: '::',
port: 3000
}
+6
View File
@@ -0,0 +1,6 @@
> wolai-frontend@0.1.0 dev F:\SOFT\MNOTE\wolai-frontend
> next dev "--port" "3000"
[?25h
ELIFECYCLE Command failed with exit code 1.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+8675
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 128 B

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

After

Width:  |  Height:  |  Size: 385 B

+11
View File
@@ -0,0 +1,11 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function LoadingDocument() {
return (
<div className="flex h-full flex-col gap-4 px-12 py-8">
<Skeleton className="h-9 w-1/3" />
<Skeleton className="h-4 w-64" />
<Skeleton className="h-[400px] w-full" />
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { notFound, redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { DocumentShell } from "@/components/editor/document-shell";
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
interface DocumentPageProps {
params: Promise<{ id: string }>;
}
export default async function DocumentPage({ params }: DocumentPageProps) {
const { id } = await params;
const supabase = await createSupabaseServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
redirect("/login");
}
const { data: document } = await supabase
.from("documents")
.select(
"id,title,content,updated_at,workspace_id,wide_layout,use_small_text,show_heading_numbers,show_toc,show_structure,protect_editing,show_word_count,word_count,character_count,block_count",
)
.eq("user_id", session.user.id)
.eq("id", id)
.single();
if (!document) {
notFound();
}
const initialOptions: PageOptionsState = {
wideLayout: document.wide_layout ?? false,
smallText: document.use_small_text ?? false,
showHeadingNumbers: document.show_heading_numbers ?? true,
showToc: document.show_toc ?? false,
showStructure: document.show_structure ?? false,
protectEditing: document.protect_editing ?? false,
showWordCount: document.show_word_count ?? true,
};
const initialStats: DocumentStats = {
wordCount: document.word_count ?? 0,
characterCount: document.character_count ?? 0,
blockCount: document.block_count ?? 0,
};
return (
<DocumentShell
documentId={document.id}
workspaceId={document.workspace_id}
title={document.title}
updatedAt={document.updated_at}
initialContent={document.content}
initialOptions={initialOptions}
initialStats={initialStats}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { Sidebar } from "@/components/sidebar/sidebar";
import { Breadcrumb } from "@/components/breadcrumb";
import { BottomToolbar } from "@/components/bottom-toolbar";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import type { DocumentRecord } from "@/lib/documents";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { SearchPalette } from "@/components/search/search-palette";
export default async function AppLayout({ children }: { children: ReactNode }) {
const supabase = await createSupabaseServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
redirect("/login");
}
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
let documents: DocumentRecord[] = [];
let sidebarInitialData: SidebarInitialData | null = null;
if (activeWorkspaceId) {
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
documents = dataset.documents;
sidebarInitialData = {
activeWorkspaceId,
workspaces,
documents: dataset.documents,
trashedDocuments: dataset.trashedDocuments,
};
}
return (
<div className="flex h-screen bg-white">
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
<MobileSidebarTrigger />
<Breadcrumb documents={documents} />
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<BottomToolbar />
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabase/client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("test@163.com");
const [password, setPassword] = useState("123456");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string>("");
const [sessionInfo, setSessionInfo] = useState<string>("");
const handleLogin = useCallback(async () => {
setLoading(true);
setMessage("");
const { data, error } = await supabaseBrowser.auth.signInWithPassword({
email,
password,
});
setLoading(false);
if (error) {
setMessage(`登录失败:${error.message}`);
return;
}
setMessage("登录成功,准备跳转...");
setSessionInfo(JSON.stringify(data.session, null, 2));
router.replace("/");
}, [email, password, router]);
const handleGetSession = useCallback(async () => {
setLoading(true);
const {
data: { session },
error,
} = await supabaseBrowser.auth.getSession();
setLoading(false);
if (error) {
setMessage(`获取会话失败:${error.message}`);
return;
}
if (!session) {
setMessage("当前未登录");
setSessionInfo("");
return;
}
setMessage("发现有效 Session");
setSessionInfo(JSON.stringify(session, null, 2));
}, []);
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 p-4">
<Card className="w-[400px] space-y-4 bg-white/90 shadow-md backdrop-blur">
<CardHeader>
<CardTitle className="text-xl">Supabase </CardTitle>
<p className="text-sm text-zinc-500">test@163.com / 123456</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<label className="text-sm text-zinc-700"></label>
<Input value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm text-zinc-700"></label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="flex gap-2">
<Button className="flex-1" onClick={handleLogin} disabled={loading}>
{loading ? "处理中..." : "登录"}
</Button>
<Button variant="outline" onClick={handleGetSession} disabled={loading}>
</Button>
</div>
{message && <p className="whitespace-pre-wrap text-sm text-blue-600">{message}</p>}
<Separator />
{sessionInfo && (
<pre className="max-h-64 overflow-auto rounded-md bg-zinc-900 p-3 text-xs text-zinc-100">
{sessionInfo}
</pre>
)}
</CardContent>
</Card>
</div>
);
}
View File
+123
View File
@@ -0,0 +1,123 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
export async function POST(request: Request) {
try {
return await handleCreateRequest(request);
} catch (error) {
console.error("创建页面失败", error);
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleCreateRequest(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { parentId }: { parentId?: string | null } = await request.json();
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope,content,user_id")
.eq("id", parentId)
.eq("user_id", session.user.id)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
parentContent = parentDoc.content;
} else {
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: siblingCount = 0, error: countError } = await siblingQuery;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId ?? null,
workspace_id: workspaceId,
title: "无标题",
content: { blocks: [] },
access_scope: accessScope,
sort_order: siblingCount,
})
.select(
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
const timestamp = new Date().toISOString();
const { error: parentUpdateError } = await supabase
.from("documents")
.update({ content: payload, updated_at: timestamp })
.eq("id", parentId)
.eq("user_id", session.user.id);
if (parentUpdateError) {
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
}
}
return NextResponse.json(data);
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
deleted_at: new Date().toISOString(),
deleted_by: session.user.id,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
+74
View File
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface DuplicatePayload {
documentId: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId }: DuplicatePayload = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { data: sourceDoc, error: sourceError } = await supabase
.from("documents")
.select("id,title,content,parent_id,workspace_id,access_scope")
.eq("id", documentId)
.eq("user_id", session.user.id)
.single();
if (sourceError || !sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", sourceDoc.workspace_id);
if (sourceDoc.parent_id) {
siblingQuery.eq("parent_id", sourceDoc.parent_id);
} else {
siblingQuery.is("parent_id", null);
}
const { count: siblingCount = 0, error: countError } = await siblingQuery;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const fallbackTitle = sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
const duplicatedTitle = `${fallbackTitle} 副本`;
const { data: duplicated, error: duplicateError } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
workspace_id: sourceDoc.workspace_id,
parent_id: sourceDoc.parent_id,
access_scope: sourceDoc.access_scope ?? "private",
title: duplicatedTitle,
content: sourceDoc.content,
sort_order: siblingCount,
})
.select("id,title,parent_id,sort_order")
.single();
if (duplicateError || !duplicated) {
return NextResponse.json({ error: duplicateError?.message ?? "复制失败" }, { status: 500 });
}
return NextResponse.json(duplicated);
}
+76
View File
@@ -0,0 +1,76 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
interface EmbedPayload {
sourceId: string;
targetId: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { sourceId, targetId }: EmbedPayload = await request.json();
if (!sourceId || !targetId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: sourceDoc, error: sourceError } = await supabase
.from("documents")
.select("id,title,user_id")
.eq("id", sourceId)
.eq("user_id", session.user.id)
.single();
if (sourceError || !sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const { data: targetDoc, error: targetError } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", targetId)
.eq("user_id", session.user.id)
.single();
if (targetError || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const currentBlocks = extractBlocksFromContent(targetDoc.content);
const nextBlocks: Json[] = [
...currentBlocks,
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceDoc.id,
title: sourceDoc.title ?? "无标题",
},
},
];
const payload: Json = composeContentWithBlocks(targetDoc.content, nextBlocks);
const { error: updateError } = await supabase
.from("documents")
.update({ content: payload })
.eq("id", targetDoc.id)
.eq("user_id", session.user.id);
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
interface EmptyTrashPayload {
workspaceId?: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
}
const { error } = await supabase
.from("documents")
.delete()
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.not("deleted_at", "is", null);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface MovePayload {
documentId: string;
parentId?: string | null;
position: number;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, parentId = null, position }: MovePayload = await request.json();
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const { error } = await supabase
.from("documents")
.update({
parent_id: parentId,
sort_order: sortOrder,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { PageOptionsState } from "@/types/page-options";
import type { Database } from "@/types/supabase";
type OptionsPayload = {
documentId: string;
options: Partial<PageOptionsState>;
};
const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Tables"]["documents"]["Row"]> = {
wideLayout: "wide_layout",
smallText: "use_small_text",
showHeadingNumbers: "show_heading_numbers",
showToc: "show_toc",
showStructure: "show_structure",
protectEditing: "protect_editing",
showWordCount: "show_word_count",
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, options }: OptionsPayload = await request.json();
if (!documentId || !options) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const updatePayload: Record<string, boolean> = {};
(Object.keys(options) as (keyof PageOptionsState)[]).forEach((key) => {
const column = COLUMN_MAP[key];
if (!column) return;
const value = options[key];
if (typeof value === "boolean") {
updatePayload[column as string] = value;
}
});
if (Object.keys(updatePayload).length === 0) {
return NextResponse.json({ error: "缺少可更新的选项" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update(updatePayload)
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
deleted_at: null,
deleted_by: null,
parent_id: null,
access_scope: "private",
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface SavePayload {
documentId: string;
content: unknown;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, content }: SavePayload = await request.json();
const { error } = await supabase
.from("documents")
.update({ content })
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentStats } from "@/types/page-options";
interface StatsPayload {
documentId: string;
stats: DocumentStats;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, stats }: StatsPayload = await request.json();
if (!documentId || !stats) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
word_count: stats.wordCount,
character_count: stats.characterCount,
block_count: stats.blockCount,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface RenamePayload {
documentId: string;
title: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, title }: RenamePayload = await request.json();
const { error } = await supabase
.from("documents")
.update({ title })
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
+92
View File
@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
let query = supabase
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.order("created_at", { ascending: false })
.limit(Number.isNaN(limit) ? 12 : limit);
const assetType = searchParams.get("assetType");
if (assetType) {
query = query.eq("asset_type", assetType);
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ items: (data ?? []) as MediaAsset[] });
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const { data, error } = await supabase
.from("media_assets")
.insert({
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: data as MediaAsset });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json()) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const { error } = await supabase
.from("media_assets")
.update({ ocr_status: "processing" })
.eq("id", assetId)
.limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl) {
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ asset_id: assetId }),
}).catch((err) => {
console.warn("触发后端 OCR 失败", err);
});
}
return NextResponse.json({ ok: true });
}
+82
View File
@@ -0,0 +1,82 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
import { extname } from "path";
export const dynamic = "force-dynamic";
const MEDIA_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "media";
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
return "file";
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
const workspaceId = String(formData.get("workspaceId") ?? "");
const documentId = String(formData.get("documentId") ?? "");
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = extname(file.name || "").replace(/\s+/g, "");
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
const path = `${workspaceId}/${Date.now()}-${uniqueId}${extension}`;
const assetType = resolveAssetType(file.type || "");
const { error: uploadError } = await supabase.storage.from(MEDIA_BUCKET).upload(path, buffer, {
contentType: file.type,
upsert: false,
});
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 });
}
const {
data: { publicUrl },
} = supabase.storage.from(MEDIA_BUCKET).getPublicUrl(path);
const { data: asset, error } = await supabase
.from("media_assets")
.insert({
workspace_id: workspaceId,
document_id: documentId,
file_url: publicUrl,
thumbnail_url: publicUrl,
asset_type: assetType,
file_name: file.name,
file_size: file.size,
mime_type: file.type,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: asset as MediaAsset });
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const pageId = searchParams.get("pageId");
const limit = Number(searchParams.get("limit") ?? "50");
const offset = Number(searchParams.get("offset") ?? "0");
if (!workspaceId || !pageId) {
return NextResponse.json({ error: "缺少 workspaceId 或 pageId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.maybeSingle();
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
const { data, error } = await supabase.rpc("list_backlinks", {
p_workspace_id: workspaceId,
p_target_page_id: pageId,
p_limit: Number.isFinite(limit) ? limit : 50,
p_offset: Number.isFinite(offset) ? offset : 0,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ backlinks: data ?? [] });
}
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
type DisplayMode = "inline" | "embed";
interface RecordReferencePayload {
workspaceId: string;
sourcePageId: string;
targetPageId: string;
sourceBlockId?: string | null;
alias?: string | null;
displayMode: DisplayMode;
isPreviewable?: boolean;
}
const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const body = (await request.json()) as RecordReferencePayload;
const { workspaceId, sourcePageId, targetPageId, sourceBlockId, alias, displayMode, isPreviewable = true } = body;
if (!workspaceId || !sourcePageId || !targetPageId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
if (!isValidDisplayMode(displayMode)) {
return NextResponse.json({ error: "非法的引用模式" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.maybeSingle();
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
const { data, error } = await supabase.rpc("record_page_ref", {
p_workspace_id: workspaceId,
p_source_page_id: sourcePageId,
p_source_block_id: sourceBlockId ?? null,
p_target_page_id: targetPageId,
p_alias: alias ?? null,
p_display_mode: displayMode,
p_is_previewable: isPreviewable,
p_created_by: session.user.id,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ reference: data });
}
+303
View File
@@ -0,0 +1,303 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type {
DocumentSearchFilters,
DocumentSearchRequest,
DocumentSearchResponse,
DocumentSearchResult,
DocumentSearchTimeRange,
} from "@/types/search";
import { buildSnippet } from "@/lib/search/snippet";
const MAX_LIMIT = 50;
const DEFAULT_FILTERS: DocumentSearchFilters = {
titleOnly: false,
exact: false,
onlyCurrentPage: false,
includeOcr: false,
timeRange: "any",
timeField: "updated",
};
const TIME_RANGE_TO_MS: Record<DocumentSearchTimeRange, number | null> = {
any: null,
"7d": 1000 * 60 * 60 * 24 * 7,
"30d": 1000 * 60 * 60 * 24 * 30,
};
const TIME_FIELD_COLUMN = {
updated: "updated_at",
created: "created_at",
} as const;
const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (match) => `\\${match}`);
interface DocumentRow {
id: string;
title: string | null;
raw_text: string | null;
updated_at: string | null;
created_at: string | null;
}
const buildIsoBoundary = (value: string, isEnd = false): string | null => {
if (!value) {
return null;
}
const normalized = value.trim();
if (!normalized) {
return null;
}
const suffix = isEnd ? "T23:59:59.999Z" : "T00:00:00.000Z";
const date = new Date(`${normalized}${suffix}`);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
};
const mapDocumentToResult = (
row: DocumentRow,
keyword: string | null,
forcedMatch?: DocumentSearchResult["matchField"],
): DocumentSearchResult => {
const normalizedKeyword = keyword?.trim() ?? "";
const normalizedTitle = row.title ?? "无标题";
let matchField: DocumentSearchResult["matchField"] = "recent";
if (forcedMatch) {
matchField = forcedMatch;
} else if (normalizedKeyword) {
const matchesTitle = normalizedTitle.toLowerCase().includes(normalizedKeyword.toLowerCase());
matchField = matchesTitle ? "title" : "content";
}
return {
id: row.id,
title: normalizedTitle,
snippet: buildSnippet(row.raw_text, normalizedKeyword || null),
updatedAt: row.updated_at,
createdAt: row.created_at,
matchField,
hasOcr: Boolean(row.raw_text),
publicPath: `/documents/${row.id}`,
score: matchField === "title" ? 2 : 1,
};
};
type RouteSupabaseClient = Awaited<ReturnType<typeof createSupabaseRouteClient>>;
const fetchRecentResults = async (
supabase: RouteSupabaseClient,
userId: string,
workspaceId: string,
): Promise<DocumentSearchResult[]> => {
const { data: recentRows, error: recentError } = await supabase
.from("user_recent_pages")
.select("document_id,last_accessed_at")
.eq("user_id", userId)
.eq("workspace_id", workspaceId)
.order("last_accessed_at", { ascending: false })
.limit(10);
if (recentError || !recentRows || recentRows.length === 0) {
return [];
}
const documentIds = recentRows.map((row) => row.document_id);
const { data: docRows, error: docsError } = await supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text")
.in("id", documentIds)
.is("deleted_at", null);
if (docsError || !docRows) {
return [];
}
const docMap = new Map(docRows.map((row) => [row.id, row]));
return recentRows
.map((row) => docMap.get(row.document_id))
.filter((row): row is DocumentRow => Boolean(row))
.map((row) => mapDocumentToResult(row, null, "recent"));
};
const fetchOcrMatches = async (
supabase: RouteSupabaseClient,
workspaceId: string,
likePattern: string,
limit: number,
) => {
const { data, error } = await supabase
.from("media_assets")
.select("document_id,ocr_text")
.eq("workspace_id", workspaceId)
.not("ocr_text", "is", null)
.ilike("ocr_text", likePattern)
.limit(limit);
if (error || !data) {
return [];
}
return data;
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const filters: DocumentSearchFilters = {
...DEFAULT_FILTERS,
...payload.filters,
};
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
if (filters.onlyCurrentPage && !payload.documentId) {
filters.onlyCurrentPage = false;
}
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const likePattern = filters.exact ? normalizedQuery : `%${escapeLike(normalizedQuery)}%`;
const timeColumn = TIME_FIELD_COLUMN[filters.timeField ?? "updated"];
let builder = supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text,is_starred,access_scope")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order(timeColumn, { ascending: false })
.limit(limit);
if (filters.onlyCurrentPage && payload.documentId) {
builder = builder.eq("id", payload.documentId);
}
const customFrom = filters.customRange?.from ? buildIsoBoundary(filters.customRange.from, false) : null;
const customTo = filters.customRange?.to ? buildIsoBoundary(filters.customRange.to, true) : null;
if (customFrom) {
builder = builder.gte(timeColumn, customFrom);
}
if (customTo) {
builder = builder.lte(timeColumn, customTo);
}
if (!customFrom && !customTo) {
const now = Date.now();
const offset = TIME_RANGE_TO_MS[filters.timeRange];
if (offset) {
const from = new Date(now - offset).toISOString();
builder = builder.gte(timeColumn, from);
}
}
if (normalizedQuery) {
if (filters.titleOnly) {
builder = filters.exact ? builder.eq("title", normalizedQuery) : builder.ilike("title", likePattern);
} else {
const clauses = [
`title.${filters.exact ? `eq.${normalizedQuery}` : `ilike.${likePattern}`}`,
];
if (filters.includeOcr) {
clauses.push(`raw_text.ilike.${likePattern}`);
}
builder = builder.or(clauses.join(","));
}
}
const shouldSearchOcr = Boolean(filters.includeOcr && normalizedQuery);
const [{ data, error }, recentResults, ocrMatches] = await Promise.all([
builder,
fetchRecentResults(supabase, session.user.id, workspaceId),
shouldSearchOcr ? fetchOcrMatches(supabase, workspaceId, likePattern, limit) : Promise.resolve([]),
]);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
let results: DocumentSearchResult[] = (data ?? []).map((row) =>
mapDocumentToResult(row, normalizedQuery || null),
);
if (ocrMatches.length > 0 && normalizedQuery) {
const snippetMap = new Map<string, string>();
ocrMatches.forEach((row: { document_id: string; ocr_text: string | null }) => {
if (!row.ocr_text) return;
if (!snippetMap.has(row.document_id)) {
snippetMap.set(row.document_id, row.ocr_text);
}
});
if (snippetMap.size > 0) {
const resultMap = new Map(results.map((item) => [item.id, item]));
const missingDocIds: string[] = [];
snippetMap.forEach((text, docId) => {
const snippet = buildSnippet(text, normalizedQuery || null);
if (resultMap.has(docId)) {
const existing = resultMap.get(docId)!;
resultMap.set(docId, {
...existing,
snippet: snippet || existing.snippet,
hasOcr: true,
matchField: "content",
});
} else {
missingDocIds.push(docId);
}
});
let extraResults: DocumentSearchResult[] = [];
if (missingDocIds.length > 0) {
const { data: extraDocs } = await supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text")
.in("id", missingDocIds)
.is("deleted_at", null);
extraResults =
extraDocs?.map((row) => {
const snippetText = snippetMap.get(row.id) ?? row.raw_text ?? "";
return {
...mapDocumentToResult(row, normalizedQuery || null, "content"),
snippet: buildSnippet(snippetText, normalizedQuery || null),
hasOcr: true,
};
}) ?? [];
}
results = [...resultMap.values(), ...extraResults];
}
}
const response: DocumentSearchResponse = {
results,
recent: recentResults,
};
return NextResponse.json(response);
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface RecentPayload {
workspaceId: string;
documentId: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId, documentId }: RecentPayload = await request.json();
if (!workspaceId || !documentId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.maybeSingle();
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
const { error } = await supabase.from("user_recent_pages").upsert(
{
user_id: session.user.id,
workspace_id: workspaceId,
document_id: documentId,
last_accessed_at: new Date().toISOString(),
},
{ onConflict: "user_id,document_id" },
);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
try {
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const payload: SidebarInitialData = {
activeWorkspaceId: targetWorkspaceId,
workspaces,
documents: dataset.documents,
trashedDocuments: dataset.trashedDocuments,
};
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
{ status: 500 },
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = await request.json().catch(() => ({}));
const workspaceId = payload.workspaceId as string | undefined;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.single();
if (membershipError || !membership) {
return NextResponse.json({ error: "无权切换至该工作空间" }, { status: 403 });
}
const { error: resetError } = await supabase
.from("workspace_members")
.update({ is_default: false })
.eq("user_id", session.user.id);
if (resetError) {
return NextResponse.json({ error: resetError.message }, { status: 500 });
}
const { error: switchError } = await supabase
.from("workspace_members")
.update({ is_default: true })
.eq("user_id", session.user.id)
.eq("workspace_id", workspaceId);
if (switchError) {
return NextResponse.json({ error: switchError.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+410
View File
@@ -0,0 +1,410 @@
@import url("https://rsms.me/inter/inter.css");
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
font-family: "Inter", system-ui, sans-serif;
}
body {
@apply bg-background text-foreground;
}
}
@layer components {
.wolai-hover {
@apply transition-colors duration-150 hover:bg-gray-50;
}
.wolai-selected {
@apply border-l-2 border-blue-500 bg-blue-50;
}
.wolai-blue {
@apply cursor-pointer text-[#2563eb] hover:underline;
}
.wolai-editor .bn-block-outer {
padding: 2px 0;
}
.wolai-editor .bn-inline-content a {
color: #2563eb;
text-decoration: underline;
}
.wolai-editor .bn-drag-handle {
opacity: 0;
transition: opacity 0.2s ease;
}
.wolai-editor .bn-block-group:hover .bn-drag-handle {
opacity: 1;
}
.wolai-editor[data-heading-numbering="true"] {
counter-reset: wolai-h1 wolai-h2 wolai-h3 wolai-h4 wolai-h5;
}
.wolai-editor[data-heading-numbering="true"] h1 {
counter-increment: wolai-h1;
counter-reset: wolai-h2;
}
.wolai-editor[data-heading-numbering="true"] h1::before {
content: counter(wolai-h1) ". ";
color: #94a3b8;
margin-right: 8px;
}
.wolai-editor[data-heading-numbering="true"] h2 {
counter-increment: wolai-h2;
counter-reset: wolai-h3;
}
.wolai-editor[data-heading-numbering="true"] h2::before {
content: counter(wolai-h1) "." counter(wolai-h2) ". ";
color: #94a3b8;
margin-right: 6px;
}
.wolai-editor[data-heading-numbering="true"] h3 {
counter-increment: wolai-h3;
counter-reset: wolai-h4;
}
.wolai-editor[data-heading-numbering="true"] h3::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". ";
color: #cbd5f5;
margin-right: 4px;
}
.wolai-editor[data-heading-numbering="true"] h4 {
counter-increment: wolai-h4;
counter-reset: wolai-h5;
}
.wolai-editor[data-heading-numbering="true"] h4::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". ";
color: #d0d7e7;
margin-right: 4px;
}
.wolai-editor[data-heading-numbering="true"] h5 {
counter-increment: wolai-h5;
}
.wolai-editor[data-heading-numbering="true"] h5::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". ";
color: #d4d4d8;
margin-right: 4px;
}
.wolai-editor-show-structure .bn-block-outer {
outline: 1px dashed #d4d4d8;
border-radius: 8px;
padding: 4px 8px;
}
.wolai-advanced-todo {
display: flex;
align-items: center;
gap: 10px;
}
.wolai-advanced-todo__status {
border-radius: 999px;
border: 1px solid #c7d2fe;
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
background-color: #eef2ff;
color: #312e81;
}
.wolai-advanced-todo__status.status-doing {
border-color: #fcd34d;
background-color: #fff7ed;
color: #92400e;
}
.wolai-advanced-todo__status.status-done {
border-color: #4ade80;
background-color: #ecfdf5;
color: #047857;
}
.wolai-advanced-todo__status.status-cancelled {
border-color: #fecdd3;
background-color: #fff1f2;
color: #9f1239;
}
.wolai-progress {
border-radius: 12px;
padding: 12px;
border: 1px solid #e5e7eb;
background-color: #f8fafc;
margin: 8px 0;
}
.wolai-progress__header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #475569;
margin-bottom: 8px;
}
.wolai-progress__mode {
font-size: 11px;
border-radius: 12px;
background-color: #e0edff;
color: #1d4ed8;
padding: 2px 8px;
}
.wolai-progress__bar {
position: relative;
width: 100%;
height: 10px;
border-radius: 999px;
background-color: #e4e4e7;
overflow: hidden;
cursor: pointer;
}
.wolai-progress__fill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
border-radius: inherit;
background-image: linear-gradient(90deg, #60a5fa, #2563eb);
}
.wolai-progress__percent {
display: block;
margin-top: 6px;
font-size: 12px;
color: #1d4ed8;
font-weight: 600;
}
.wolai-progress__description {
font-size: 13px;
color: #6b7280;
margin-top: 6px;
}
.wolai-media {
position: relative;
margin: 24px 0;
border-radius: 18px;
}
.wolai-media::after {
content: "";
position: absolute;
inset: -8px;
border: 1px solid transparent;
border-radius: 24px;
transition: border-color 0.2s ease;
pointer-events: none;
}
.wolai-media:hover::after,
.wolai-media:focus-within::after {
border-color: rgba(37, 99, 235, 0.15);
}
.wolai-media--empty {
border: 1px dashed #bed5ff;
background-color: #fff;
padding: 20px;
border-radius: 16px;
}
.wolai-media__canvas {
position: relative;
border-radius: 18px;
overflow: hidden;
background-color: #fff;
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.05);
margin: 0 auto;
}
.wolai-media__figure {
border-radius: inherit;
overflow: hidden;
background-color: #fff;
}
.wolai-media__figure img {
width: 100%;
border-radius: inherit;
object-fit: cover;
}
.wolai-media__figure--border img {
border: 1px solid #e2e8f0;
}
.wolai-media__figure figcaption {
padding: 8px 12px;
}
.wolai-media__figure--center figcaption {
text-align: center;
}
.wolai-media__figure--right figcaption {
text-align: right;
}
.wolai-media__quickbar {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
padding: 4px;
border-radius: 9999px;
background-color: rgba(15, 23, 42, 0.85);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.35);
opacity: 0;
transform: translateY(-4px);
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
.wolai-media:hover .wolai-media__quickbar,
.wolai-media:focus-within .wolai-media__quickbar {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.wolai-media__quickbutton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9999px;
background-color: transparent;
color: #e2e8f0;
border: none;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.wolai-media__quickbutton:hover,
.wolai-media__quickbutton:focus-visible {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
outline: none;
}
.wolai-media__hint {
margin-top: 6px;
font-size: 12px;
color: #94a3b8;
text-align: left;
}
.wolai-media__resize-handle {
position: absolute;
top: 50%;
width: 12px;
height: 44px;
border-radius: 2px;
background: rgba(15, 23, 42, 0.5);
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.15s ease;
cursor: ew-resize;
z-index: 20;
}
.wolai-media__resize-handle--left {
left: -6px;
}
.wolai-media__resize-handle--right {
right: -6px;
}
.wolai-media:hover .wolai-media__resize-handle,
.wolai-media:focus-within .wolai-media__resize-handle {
opacity: 1;
}
.wolai-media__resize-handle.is-dragging {
background: rgba(37, 99, 235, 0.85);
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { SupabaseProvider } from "@/components/providers/supabase-provider";
import { QueryProvider } from "@/components/providers/query-provider";
import { createSupabaseServerClient } from "@/lib/supabase/server";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "Wolai Clone",
description: "Stage0 + Stage1 implementation powered by Supabase",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createSupabaseServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
return (
<html lang="zh-CN">
<body className={`${inter.variable} antialiased`}>
<SupabaseProvider session={session}>
<QueryProvider>{children}</QueryProvider>
</SupabaseProvider>
</body>
</html>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
export default async function Home() {
const supabase = await createSupabaseServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
redirect("/login");
}
const { data: firstDoc } = await supabase
.from("documents")
.select("id")
.eq("user_id", session.user.id)
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle();
if (firstDoc?.id) {
redirect(`/documents/${firstDoc.id}`);
}
const { data: createdDoc, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
title: "新页面",
content: {},
})
.select("id")
.single();
if (error || !createdDoc) {
redirect("/login");
}
redirect(`/documents/${createdDoc.id}`);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { MessageCircle, Sparkles, Wand2 } from "lucide-react";
import { useBackendHealth } from "@/hooks/use-backend-health";
import { cn } from "@/lib/utils";
export function BottomToolbar() {
const status = useBackendHealth();
const indicatorColor =
status === "ok" ? "bg-green-500" : status === "error" ? "bg-red-500" : "bg-gray-300";
return (
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
{status === "ok" ? "正常" : status === "error" ? "异常" : "检测中"}
</div>
<div className="flex items-center gap-3 text-gray-600">
<button type="button" className="wolai-hover rounded-full px-3 py-1">
<MessageCircle className="mr-1 inline h-4 w-4" />
</button>
<button type="button" className="wolai-hover rounded-full px-3 py-1">
<Wand2 className="mr-1 inline h-4 w-4" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-sm"
>
<Sparkles className="h-4 w-4" />
</button>
</div>
</footer>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { useParams, useSelectedLayoutSegments } from "next/navigation";
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
import type { DocumentRecord } from "@/lib/documents";
import { findBreadcrumb } from "@/lib/documents";
import { usePageLayoutStore } from "@/store/page-layout";
interface BreadcrumbProps {
documents: DocumentRecord[];
}
export function Breadcrumb({ documents }: BreadcrumbProps) {
const segments = useSelectedLayoutSegments();
const params = useParams<{ id?: string }>();
const paramId = typeof params?.id === "string" ? params.id : "";
const activeId = paramId || segments?.[1] || "";
const path = findBreadcrumb(documents, activeId);
const showInspector = usePageLayoutStore((state) => state.showInspector);
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
if (!path.length) {
return <div className="text-sm text-gray-400"></div>;
}
return (
<div className="flex flex-1 items-center justify-between">
<nav className="flex items-center text-sm text-gray-500">
{path.map((node, index) => {
const isLast = index === path.length - 1;
return (
<span key={node.id} className="flex items-center">
{index > 0 && <ChevronRight className="mx-1 h-4 w-4 text-gray-400" />}
{isLast ? (
<span className="font-medium text-gray-900">{node.title || "无标题"}</span>
) : (
<Link href={`/documents/${node.id}`} className="wolai-blue">
{node.title || "无标题"}
</Link>
)}
</span>
);
})}
</nav>
<div className="flex items-center gap-2 text-gray-500">
<button type="button" className="wolai-hover rounded-full px-3 py-1 text-sm">
<Star className="mr-1 inline h-4 w-4" />
</button>
<button
type="button"
className="wolai-hover rounded-full p-2 text-gray-500"
onClick={toggleInspector}
aria-label={showInspector ? "隐藏页面选项" : "显示页面选项"}
title={showInspector ? "隐藏页面选项" : "显示页面选项"}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</div>
);
}
View File
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSessionContext } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface TaskResponse {
task_id: string;
status: string;
progress: number;
message?: string | null;
}
interface Props {
documentId: string;
}
export function DocumentTaskPanel({ documentId }: Props) {
const { session } = useSessionContext();
const [task, setTask] = useState<TaskResponse | null>(null);
const [pending, setPending] = useState(false);
const backendUrl = useMemo(() => process.env.NEXT_PUBLIC_BACKEND_URL, []);
const triggerTask = async () => {
if (!backendUrl || !session?.access_token) return;
setPending(true);
try {
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({
document_id: documentId,
file_url: "https://example.com/sample.pdf",
}),
});
const data = await response.json();
if (response.ok) {
setTask(data);
}
} finally {
setPending(false);
}
};
useEffect(() => {
if (!backendUrl || !session?.access_token || !task?.task_id) {
return;
}
const timer = setInterval(async () => {
const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
return;
}
const data = (await response.json()) as TaskResponse;
setTask(data);
if (data.status === "completed") {
clearInterval(timer);
}
}, 2000);
return () => clearInterval(timer);
}, [backendUrl, session?.access_token, task?.task_id]);
return (
<Card className="mt-4 bg-white shadow-sm">
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600">
<div>
<div className="font-medium text-gray-900"> OCR </div>
<div className="text-xs text-gray-500">
{task ? task.status : "未开始"} · {task ? `${task.progress}%` : "0%"}
</div>
{task?.message && <div className="text-xs text-gray-500">{task.message}</div>}
</div>
<Button onClick={triggerTask} disabled={pending} variant="outline">
{pending ? "触发中..." : "触发 OCR"}
</Button>
</CardContent>
</Card>
);
}
View File
+585
View File
@@ -0,0 +1,585 @@
"use client";
import "@blocknote/core/style.css";
import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import {
SideMenuController,
useCreateBlockNote,
type SideMenuProps,
} from "@blocknote/react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";
import type { Block } from "@blocknote/core";
import { cn } from "@/lib/utils";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
interface BlockNoteEditorProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
pageOptions: PageOptionsState;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
}
const extractInitialBlocks = (content: unknown): Json | undefined => {
if (Array.isArray(content) && content.length > 0) {
return content as Json;
}
if (content && typeof content === "object") {
const maybeBlocks = (content as Record<string, unknown>).blocks;
if (Array.isArray(maybeBlocks) && maybeBlocks.length > 0) {
return maybeBlocks as Json;
}
}
return undefined;
};
const extractInlineText = (block: Block<CustomBlockSchema>): string => {
const inlineNodes = (block.content ?? []) as Array<{ text?: string }>;
return inlineNodes.map((node) => (typeof node.text === "string" ? node.text : "")).join("").trim();
};
const buildHeadingToc = (blocks: Block<CustomBlockSchema>[]): TocEntry[] => {
const counters = [0, 0, 0, 0, 0];
const entries: TocEntry[] = [];
const walk = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "heading") {
const level = Math.min(5, Math.max(1, Number(block.props.level) || 1));
counters[level - 1] += 1;
for (let i = level; i < counters.length; i += 1) {
counters[i] = 0;
}
const numbering = counters.slice(0, level).filter((value) => value > 0).join(".");
entries.push({
id: block.id,
level,
numbering,
title: extractInlineText(block),
});
}
if (block.children && block.children.length > 0) {
walk(block.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return entries;
};
const findBlockById = (
blocks: Block<CustomBlockSchema>[],
id: string,
): Block<CustomBlockSchema> | undefined => {
for (const block of blocks) {
if (block.id === id) return block;
if (block.children && block.children.length > 0) {
const child = findBlockById(block.children as Block<CustomBlockSchema>[], id);
if (child) return child;
}
}
return undefined;
};
const syncProgressMeters = (editorInstance: ReturnType<typeof useCreateBlockNote>) => {
if (!editorInstance) {
return;
}
const blocks = editorInstance.topLevelBlocks as Block<CustomBlockSchema>[];
const progressStats = new Map<
string,
{ done: number; doing: number; total: number }
>();
let activeProgressId: string | null = null;
const traverse = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "progressMeter" && block.props.auto) {
activeProgressId = block.id;
progressStats.set(block.id, { done: 0, doing: 0, total: 0 });
} else if (block.type === "progressMeter" && !block.props.auto) {
activeProgressId = null;
} else if (block.type === "heading") {
activeProgressId = null;
} else if (block.type === "advancedTodo" && activeProgressId) {
const currentStat = progressStats.get(activeProgressId);
if (!currentStat) return;
if (block.props.status === "cancelled") {
return;
}
currentStat.total += 1;
if (block.props.status === "done") {
currentStat.done += 1;
} else if (block.props.status === "doing") {
currentStat.doing += 1;
}
}
if (block.children && block.children.length > 0) {
traverse(block.children as Block<CustomBlockSchema>[]);
}
});
};
traverse(blocks);
progressStats.forEach((stat, progressId) => {
const block = findBlockById(blocks, progressId);
if (!block) return;
const weightedDone = stat.done + stat.doing * 0.5;
const percent = stat.total === 0 ? 0 : Math.min(100, Math.round((weightedDone / stat.total) * 100));
const summary = stat.total === 0 ? "暂无条目" : `${stat.done}/${stat.total} 完成`;
if (block.props.percent !== percent || block.props.summary !== summary) {
editorInstance.updateBlock(block, {
props: {
percent,
summary,
},
});
}
});
};
export function BlockNoteEditor({
documentId,
workspaceId,
initialContent,
pageOptions,
onStatsChange,
onSnapshot,
}: BlockNoteEditorProps) {
const [isSaving, setIsSaving] = useState(false);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
const normalizedInitialContent = useMemo(
() => extractInitialBlocks(initialContent),
[initialContent],
);
const collaboration = useMemo(() => {
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
if (!url) return null;
const doc = new Y.Doc();
const provider = new HocuspocusProvider({
url,
name: `document.${documentId}`,
document: doc,
});
return { doc, provider };
}, [documentId]);
const editor = useCreateBlockNote(
{
initialContent: normalizedInitialContent as never,
schema: customBlockSchema,
collaboration: collaboration
? {
provider: collaboration.provider,
fragment: collaboration.doc.getXmlFragment("wolai"),
user: {
name: "访客",
color: "#2563eb",
},
}
: undefined,
},
[documentId],
);
useEffect(
() => () => {
collaboration?.provider.destroy();
collaboration?.doc.destroy();
},
[collaboration],
);
const saveContent = useCallback(
async (content: Json) => {
setIsSaving(true);
try {
await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, content }),
});
} finally {
setIsSaving(false);
}
},
[documentId],
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
useEffect(() => {
if (!editor) {
return undefined;
}
let disposed = false;
const runSync = () => {
if (disposed) {
return;
}
const blocks = editor.topLevelBlocks;
debouncedSave(blocks as Json);
const typedBlocks = blocks as Block<CustomBlockSchema>[];
setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats });
};
runSync();
const unsubscribe = editor.onEditorContentChange(runSync);
return () => {
disposed = true;
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, [editor, debouncedSave, onSnapshot, onStatsChange]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, []);
const editorWrapperClass = cn(
"relative min-h-[60vh] rounded-2xl border border-transparent bg-white p-4 shadow-sm",
pageOptions.smallText ? "text-[15px]" : "text-[16px]",
);
const blocknoteClass = cn(
"wolai-editor min-h-full",
pageOptions.showStructure && "wolai-editor-show-structure",
);
const buildDocumentPath = (documentId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${documentId}`;
}
return `${window.location.origin}/documents/${documentId}`;
};
const trimTrailingCharacter = (
editorInstance: ReturnType<typeof useCreateBlockNote> | null,
block: Block<CustomBlockSchema>,
char: string,
) => {
if (!editorInstance) {
return;
}
const content = Array.isArray(block.content) ? [...block.content] : [];
for (let index = content.length - 1; index >= 0; index -= 1) {
const node = content[index] as { text?: string };
if (typeof node?.text === "string" && node.text.endsWith(char)) {
const nextText = node.text.slice(0, -1);
if (nextText.length === 0) {
content.splice(index, 1);
} else {
content[index] = { ...node, text: nextText };
}
editorInstance.updateBlock(block, { content });
break;
}
}
};
const generateBlockId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `ref_${Math.random().toString(36).slice(2, 10)}`;
};
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
let characterCount = 0;
let wordCount = 0;
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (Array.isArray(block.content)) {
block.content.forEach((node: { text?: string }) => {
if (typeof node.text === "string") {
const text = node.text;
characterCount += text.length;
const trimmed = text.trim();
if (trimmed.length === 0) {
return;
}
const tokens = trimmed.split(/\s+/).filter(Boolean);
if (tokens.length > 1) {
wordCount += tokens.length;
} else {
wordCount += trimmed.replace(/\s+/g, "").length;
}
}
});
}
if (block.children && block.children.length > 0) {
accumulate(block.children as Block<CustomBlockSchema>[]);
}
});
};
accumulate(blocks);
return {
wordCount,
characterCount,
blockCount: blocks.length,
};
};
const insertMediaAssetBlock = useCallback(
(asset: MediaAsset) => {
if (!editor) {
return;
}
const fileUrl = asset.file_url ?? "";
if (!fileUrl) {
return;
}
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
if (!referenceBlock) {
return;
}
editor.insertBlocks(
[
{
type: "media",
props: {
fileUrl,
thumbnailUrl: asset.thumbnail_url ?? fileUrl,
assetId: asset.id,
assetType: asset.asset_type ?? "image",
fileName: asset.file_name ?? "",
fileSize: asset.file_size ?? null,
mimeType: asset.mime_type ?? "",
ocrStatus: asset.ocr_status ?? "idle",
},
},
],
referenceBlock,
"after",
);
},
[editor],
);
const uploadClipboardMedia = useCallback(
async (file: File) => {
if (!workspaceId) {
throw new Error("缺少空间信息,无法上传文件");
}
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset) {
throw new Error("上传返回数据缺失");
}
return payload.asset;
},
[documentId, workspaceId],
);
useEffect(() => {
if (!editor) {
registerEditorBridge(null);
return;
}
const bridge = {
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null;
const text = aliasText || target.title || "无标题";
editor.insertInlineContent([
{
type: "link",
href: buildDocumentPath(target.id),
content: text,
},
{ type: "text", text: " " },
]);
return { blockId };
},
insertEmbedReference: (target: ReferenceTarget) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
const blockId = generateBlockId();
editor.insertBlocks(
[
{
id: blockId,
type: "pageReference",
props: {
pageId: target.id,
title: target.title ?? "无标题",
},
},
],
referenceBlock,
"after",
);
return { blockId };
},
replaceWithSnapshot: (payload: Json) => {
editor.focus();
const nextBlocks = Array.isArray(payload)
? payload
: Array.isArray((payload as { blocks?: Json }).blocks)
? ((payload as { blocks?: Json }).blocks as Json)
: [];
if (!Array.isArray(nextBlocks)) {
return;
}
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
},
};
registerEditorBridge(bridge);
return () => registerEditorBridge(null);
}, [editor, registerEditorBridge]);
useEffect(() => {
if (!editor) {
return undefined;
}
const handlePaste = (event: ClipboardEvent) => {
const activeElement = event.target;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const items = Array.from(event.clipboardData?.files ?? []);
if (items.length === 0) {
return;
}
const imageFile = items.find((candidate) => candidate.type?.startsWith("image/"));
if (!imageFile) {
return;
}
event.preventDefault();
void (async () => {
try {
const asset = await uploadClipboardMedia(imageFile);
insertMediaAssetBlock(asset);
} catch (error) {
console.error(error);
window.alert((error as Error).message ?? "粘贴图片失败,请稍后重试");
}
})();
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
useEffect(() => {
if (!editor) {
return;
}
const buffer = { char: "", blockId: "", timestamp: 0 };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "[" && event.key !== "#") {
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
return;
}
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const { block } = editor.getTextCursorPosition();
if (!block) {
return;
}
const now = Date.now();
if (
buffer.char === event.key &&
buffer.blockId === block.id &&
now - buffer.timestamp < 450
) {
event.preventDefault();
trimTrailingCharacter(editor, block, event.key);
openReferencePalette({
referenceMode: event.key === "[" ? "inline" : "embed",
});
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
} else {
buffer.char = event.key;
buffer.blockId = block.id;
buffer.timestamp = now;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editor, openReferencePalette]);
const layoutClass = cn(
"relative mx-auto w-full",
pageOptions.wideLayout ? "max-w-none" : "max-w-[980px]",
);
return (
<div className={layoutClass}>
<div className={editorWrapperClass}>
<BlockNoteView
editor={editor}
theme="light"
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
editable={!pageOptions.protectEditing}
className={blocknoteClass}
>
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
{isSaving ? "保存中..." : "已保存"}
</div>
</div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { createReactBlockSpec } from "@blocknote/react";
const TODO_STATES = ["todo", "doing", "done", "cancelled"] as const;
const STATUS_LABELS: Record<(typeof TODO_STATES)[number], string> = {
todo: "未开始",
doing: "进行中",
done: "已完成",
cancelled: "已取消",
};
export const advancedTodoBlock = createReactBlockSpec(
{
type: "advancedTodo",
propSchema: {
status: {
default: "todo",
values: TODO_STATES,
},
},
content: "inline",
},
{
render: ({ block, editor }) => {
const updateStatus = (next: (typeof TODO_STATES)[number]) => {
editor.updateBlock(block, { props: { status: next } });
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const current = block.props.status as (typeof TODO_STATES)[number];
if (event.altKey) {
updateStatus("cancelled");
return;
}
const index = TODO_STATES.indexOf(current);
const nextState = TODO_STATES[(index + 1) % TODO_STATES.length];
updateStatus(nextState);
};
return (
<div className="wolai-advanced-todo">
<button
type="button"
className={`wolai-advanced-todo__status status-${block.props.status}`}
onClick={handleClick}
>
{STATUS_LABELS[block.props.status as (typeof TODO_STATES)[number]]}
</button>
<div className="wolai-advanced-todo__content" />
</div>
);
},
},
)();
+498
View File
@@ -0,0 +1,498 @@
"use client";
import {
useEffect,
useRef,
useState,
useMemo,
type JSX,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind } from "@/types/media";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomBlockSchema } from "../schema";
type MediaAlign = "left" | "center" | "right";
type MediaBlockRenderProps = {
block: Block<CustomBlockSchema>;
editor: BlockNoteEditor<CustomBlockSchema>;
};
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
image: "图片",
video: "视频",
audio: "音频",
file: "文件",
};
const deriveFileName = (value?: string) => {
if (!value) {
return "未命名资源";
}
try {
const url = new URL(value);
const last = url.pathname.split("/").filter(Boolean).pop();
if (last) {
return decodeURIComponent(last);
}
} catch {
const segments = value.split("?")[0]?.split("/") ?? [];
const last = segments.pop();
if (last) {
return decodeURIComponent(last);
}
}
return "未命名资源";
};
const formatFileSize = (size?: number | null) => {
if (!size || size <= 0) {
return "未知大小";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let idx = 0;
let current = size;
while (current >= 1024 && idx < units.length - 1) {
current /= 1024;
idx += 1;
}
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
};
const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
const fileUrl = block.props.fileUrl as string;
const rawAssetType = (block.props.assetType as string) || "image";
const assetType: MediaKind =
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
? (rawAssetType as MediaKind)
: "image";
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
const canAlign = assetType === "image" || assetType === "video";
const canToggleBorder = assetType === "image";
const canTriggerOcr = assetType === "image";
const canResize = assetType === "image" || assetType === "video";
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
const mediaRef = useRef<HTMLDivElement | null>(null);
const latestWidthRef = useRef(localWidth);
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
const captionRef = useRef<HTMLInputElement | null>(null);
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const handleChoose = () => {
openPicker({
defaultTab: fileUrl ? "recent" : "upload",
mediaType: assetType,
onSelect: (selection) => {
editor.updateBlock(block, {
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? rawAssetType,
fileName: selection.fileName ?? block.props.fileName ?? "",
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
ocrStatus: "idle",
},
});
},
});
};
const toggleBorder = () => {
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
};
const setAlign = (align: MediaAlign) => {
editor.updateBlock(block, { props: { captionAlign: align } });
};
const handleCaptionChange = (value: string) => {
editor.updateBlock(block, { props: { caption: value } });
};
const enableCaptionEdit = () => {
setCaptionEditing(true);
setTimeout(() => captionRef.current?.focus(), 0);
};
useEffect(() => {
if (!shouldShowCaption && captionEditing) {
setCaptionEditing(false);
}
}, [captionEditing, shouldShowCaption]);
useEffect(() => {
if (!dragging) {
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
}
}, [block.props.width, dragging]);
useEffect(() => {
latestWidthRef.current = localWidth;
}, [localWidth]);
const resolvedWidth = useMemo(() => {
if (!canResize) return 0;
if (localWidth > 0) return clampWidth(localWidth);
if (block.props.width && Number(block.props.width) > 0) {
return clampWidth(Number(block.props.width));
}
return 0;
}, [block.props.width, canResize, localWidth]);
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
if (!canResize) return;
event.preventDefault();
event.stopPropagation();
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
if (!canvasWidth) {
return;
}
setDragging({
side,
startX: event.clientX,
startWidth: canvasWidth,
});
};
useEffect(() => {
if (!dragging) {
return undefined;
}
const handleMove = (event: MouseEvent) => {
event.preventDefault();
const delta = event.clientX - dragging.startX;
const adjusted = dragging.side === "left" ? -delta : delta;
const next = clampWidth(dragging.startWidth + adjusted);
setLocalWidth(next);
};
const handleUp = () => {
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
setDragging(null);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [dragging, editor, block]);
const handleLink = () => {
const next = window.prompt(`输入${typeLabel}跳转链接`, block.props.linkUrl ?? "");
if (next === null) return;
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
};
const viewOriginal = () => {
if (!fileUrl) return;
window.open(fileUrl, "_blank", "noopener,noreferrer");
};
const downloadAsset = () => {
if (!fileUrl) return;
const anchor = document.createElement("a");
anchor.href = fileUrl;
anchor.download = block.props.fileName || block.props.caption || typeLabel;
anchor.click();
};
const triggerOcr = async () => {
if (!block.props.assetId) {
window.alert("请先上传图片后再执行 OCR");
return;
}
setBusy(true);
try {
const response = await fetch("/api/media/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: block.props.assetId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "触发 OCR 失败");
}
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
} catch (error) {
window.alert((error as Error).message);
} finally {
setBusy(false);
}
};
if (!fileUrl) {
return (
<div className="wolai-media wolai-media--empty">
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
<ImageIcon className="h-4 w-4" />
{typeLabel}
</Button>
<p className="text-xs text-gray-500"></p>
</div>
);
}
const renderPreviewContent = () => {
if (assetType === "video") {
return (
<video
controls
className="max-h-[420px] w-full rounded-2xl bg-black"
poster={block.props.thumbnailUrl || undefined}
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
>
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
</video>
);
}
if (assetType === "audio") {
return (
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
<audio controls className="w-full">
<source src={fileUrl} type={block.props.mimeType || "audio/mpeg"} />
</audio>
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
</div>
);
}
if (assetType === "file") {
return (
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<span className="rounded-full bg-[#2563eb]/10 p-3 text-[#2563eb]">
<Paperclip className="h-5 w-5" />
</span>
<div className="flex-1">
<p className="text-sm font-medium text-gray-800">{displayFileName}</p>
{block.props.fileSize ? (
<p className="text-xs text-gray-500">{formatFileSize(block.props.fileSize)}</p>
) : null}
</div>
</div>
);
}
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
};
const figure = (
<figure
className={cn(
"wolai-media__figure",
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
)}
>
<div className="wolai-media__preview">{renderPreviewContent()}</div>
{shouldShowCaption && (
<figcaption>
<input
ref={captionRef}
value={block.props.caption ?? ""}
onChange={(event) => handleCaptionChange(event.target.value)}
onBlur={() => setCaptionEditing(false)}
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
/>
</figcaption>
)}
</figure>
);
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
const quickActions: QuickAction[] = [
{
key: "replace",
label: `替换${typeLabel}`,
icon: <RefreshCcw className="h-4 w-4" />,
onClick: handleChoose,
},
canToggleBorder
? {
key: "border",
label: block.props.hasBorder ? "取消边框" : "显示边框",
icon: <ImageIcon className="h-4 w-4" />,
onClick: toggleBorder,
}
: null,
!shouldShowCaption
? {
key: "caption",
label: "添加说明",
icon: <Type className="h-4 w-4" />,
onClick: enableCaptionEdit,
}
: null,
{
key: "link",
label: block.props.linkUrl ? "编辑链接" : "添加链接",
icon: <LinkIcon className="h-4 w-4" />,
onClick: handleLink,
},
{
key: "download",
label: `下载${typeLabel}`,
icon: <Download className="h-4 w-4" />,
onClick: downloadAsset,
},
].filter((action): action is QuickAction => Boolean(action));
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
return (
<div className="wolai-media" ref={mediaRef}>
<div className="wolai-media__canvas" style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}>
{block.props.linkUrl ? (
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
{figure}
</a>
) : (
figure
)}
<div className="wolai-media__quickbar">
{quickActions.map((action) => (
<button
key={action.key}
type="button"
className="wolai-media__quickbutton"
onClick={action.onClick}
title={action.label}
aria-label={action.label}
>
{action.icon}
</button>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleChoose}></DropdownMenuItem>
{!shouldShowCaption && (
<DropdownMenuItem onClick={enableCaptionEdit}></DropdownMenuItem>
)}
{canToggleBorder && (
<DropdownMenuItem onClick={toggleBorder}>
{block.props.hasBorder ? "取消边框" : "显示边框"}
</DropdownMenuItem>
)}
{canAlign && (
<>
<DropdownMenuLabel className="text-xs text-gray-400"></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setAlign("left")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("center")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("right")}></DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}></DropdownMenuItem>
<DropdownMenuItem onClick={viewOriginal}></DropdownMenuItem>
<DropdownMenuItem onClick={downloadAsset}></DropdownMenuItem>
{canTriggerOcr && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{canResize && (
<>
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
<ResizeHandle side="right" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "right")} />
</>
)}
</div>
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
</div>
);
};
export const mediaBlock = createReactBlockSpec(
{
type: "media",
propSchema: {
fileUrl: { default: "", type: "string" },
thumbnailUrl: { default: "", type: "string" },
caption: { default: "", type: "string" },
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
hasBorder: { default: true, type: "boolean" },
linkUrl: { default: "", type: "string" },
assetId: { default: "", type: "string" },
assetType: { default: "image", type: "string" },
fileName: { default: "", type: "string" },
fileSize: { default: 0, type: "number" },
mimeType: { default: "", type: "string" },
width: { default: 0, type: "number" },
ocrStatus: { default: "idle", type: "string" },
},
content: "none",
},
{
render: (props) => <MediaBlockContent {...props} />,
},
)();
const handleCopyLink = async (targetUrl: string | null) => {
if (!targetUrl) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(targetUrl);
window.alert("链接已复制");
} else {
throw new Error("no clipboard");
}
} catch {
window.prompt("请复制以下链接", targetUrl);
}
};
const ResizeHandle = ({
side,
onMouseDown,
dragging,
}: {
side: "left" | "right";
dragging: boolean;
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
}) => (
<span
role="separator"
tabIndex={0}
aria-orientation="horizontal"
onMouseDown={onMouseDown}
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
/>
);
const clampWidth = (value: number) => {
const min = 240;
const max = 960;
if (Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
};
@@ -0,0 +1,50 @@
"use client";
import { createReactBlockSpec } from "@blocknote/react";
export const progressBlock = createReactBlockSpec(
{
type: "progressMeter",
propSchema: {
percent: { default: 0, type: "number" },
auto: { default: true, type: "boolean" },
summary: { default: "", type: "string" },
},
content: "inline",
},
{
render: ({ block, editor }) => {
const percent = block.props.percent ?? 0;
const handleToggle = () => {
editor.updateBlock(block, { props: { auto: !block.props.auto } });
};
const handleBarClick = () => {
if (block.props.auto) return;
const input = window.prompt("设置进度(0-100", percent.toString());
if (!input) return;
const value = Number.parseInt(input, 10);
if (Number.isNaN(value)) return;
editor.updateBlock(block, {
props: { percent: Math.min(100, Math.max(0, value)) },
});
};
return (
<div className="wolai-progress">
<div className="wolai-progress__header">
<span className="wolai-progress__summary">{block.props.summary || "暂无条目"}</span>
<button type="button" className="wolai-progress__mode" onClick={handleToggle}>
{block.props.auto ? "自动" : "手动"}
</button>
</div>
<div className="wolai-progress__bar" onClick={handleBarClick}>
<div className="wolai-progress__fill" style={{ width: `${percent}%` }} />
</div>
<span className="wolai-progress__percent">{percent}%</span>
<div className="wolai-progress__description" />
</div>
);
},
},
)();
+259
View File
@@ -0,0 +1,259 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from "react";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout";
import type { Json } from "@/types/supabase";
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
);
export interface DocumentContentProps {
documentId: string;
workspaceId: string;
title: string | null;
updatedAt: string | null;
initialContent: unknown;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
}
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
export function DocumentContent({
documentId,
workspaceId,
title,
updatedAt,
initialContent,
initialOptions,
initialStats,
}: DocumentContentProps) {
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const showInspector = usePageLayoutStore((state) => state.showInspector);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setPageTitle(title ?? "无标题");
}, [title]);
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
/* eslint-enable react-hooks/set-state-in-effect */
const persistTitle = useCallback(
async (nextTitle: string) => {
const payload = nextTitle.trim() || "无标题";
await fetch("/api/documents/title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, title: payload }),
});
},
[documentId],
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
void persistTitle(value);
}, 600);
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setPageTitle(value);
debouncedPersistTitle(value);
};
const handleTitleBlur = () => {
void persistTitle(pageTitle);
};
const handleTitleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
};
const persistOptions = useCallback(
async (patch: Partial<PageOptionsState>) => {
try {
const response = await fetch("/api/documents/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, options: patch }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
console.error(payload?.error ?? "更新页面选项失败");
}
} catch (error) {
console.error(error);
}
},
[documentId],
);
const toggleOption = (key: keyof PageOptionsState) => {
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
});
};
const formattedUpdatedAt = useMemo(() => {
if (!updatedAt) return "";
return new Date(updatedAt).toLocaleString();
}, [updatedAt]);
const handleExport = useCallback(() => {
const latest = history[0];
if (!latest) {
window.alert("暂无可导出的内容");
return;
}
const payload = JSON.stringify(latest.blocks, null, 2);
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`;
anchor.click();
URL.revokeObjectURL(url);
}, [history, title]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setHistory((prev) => {
const now = Date.now();
if (prev.length > 0 && now - prev[0].timestamp < 4000) {
return prev;
}
const snapshot: DocumentSnapshot = {
id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: now,
blocks: payload.blocks,
stats: payload.stats,
};
return [snapshot, ...prev].slice(0, 15);
});
}, []);
const persistStatsRequest = useCallback((next: DocumentStats) => {
void fetch("/api/documents/stats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, stats: next }),
}).catch((error) => console.error(error));
}, [documentId]);
const persistStats = useDebouncedCallback(persistStatsRequest, 1500);
const handleStatsChange = useCallback(
(nextStats: DocumentStats) => {
setStats(nextStats);
persistStats(nextStats);
},
[persistStats],
);
const handleRestoreSnapshot = useCallback(
(snapshot: DocumentSnapshot) => {
if (!editorBridge) {
window.alert("编辑器尚未准备好,无法恢复历史版本");
return;
}
editorBridge.replaceWithSnapshot(snapshot.blocks);
setHistoryOpen(false);
},
[editorBridge],
);
return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className="flex h-full overflow-hidden bg-white">
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-[#f5f5f5] px-12 pb-6 pt-8">
<div className="relative">
<input
value={pageTitle}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
placeholder="无标题"
className="w-full border-none bg-transparent text-3xl font-semibold text-[#333333] outline-none focus:ring-0"
aria-label="页面标题"
disabled={options.protectEditing}
/>
</div>
{options.protectEditing && (
<p className="mt-1 text-sm text-[#b91c1c]"></p>
)}
<p className="text-sm text-gray-400">{formattedUpdatedAt}</p>
</div>
<div className="flex-1 overflow-y-auto px-12 py-6">
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={initialContent}
pageOptions={options}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
/>
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
</div>
</div>
{showInspector && (
<PageOptionsSidebar
documentId={documentId}
options={options}
stats={stats}
onToggle={toggleOption}
onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)}
/>
)}
</div>
<DocumentHistoryDrawer
open={historyOpen}
onOpenChange={setHistoryOpen}
history={history}
onRestore={handleRestoreSnapshot}
/>
</ImagePickerProvider>
);
}
@@ -0,0 +1,51 @@
"use client";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import type { DocumentSnapshot } from "@/types/document";
interface DocumentHistoryDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
history: DocumentSnapshot[];
onRestore: (snapshot: DocumentSnapshot) => void;
}
export function DocumentHistoryDrawer({ open, onOpenChange, history, onRestore }: DocumentHistoryDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader className="text-left">
<DrawerTitle></DrawerTitle>
<DrawerDescription> 15 </DrawerDescription>
</DrawerHeader>
<div className="space-y-3 px-4 pb-6">
{history.length === 0 ? (
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
</div>
) : (
history.map((snapshot) => (
<div
key={snapshot.id}
className="flex items-center justify-between rounded-lg border border-[#e2e8f0] bg-white px-4 py-3 text-sm"
>
<div>
<div className="font-medium text-gray-800">
{new Date(snapshot.timestamp).toLocaleString()}
</div>
<div className="text-xs text-gray-500">
{snapshot.stats.wordCount} · {snapshot.stats.characterCount}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => onRestore(snapshot)}>
</Button>
</div>
))
)}
</div>
</DrawerContent>
</Drawer>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import type { DocumentContentProps } from "@/components/editor/document-content";
const DocumentContent = dynamic(
() => import("@/components/editor/document-content").then((mod) => mod.DocumentContent),
{
ssr: false,
loading: () => (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
),
},
);
export function DocumentShell(props: DocumentContentProps) {
const [mounted, setMounted] = useState(false);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setMounted(true);
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
if (!mounted) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
);
}
return <DocumentContent {...props} />;
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
export interface TocEntry {
id: string;
title: string;
level: number;
numbering: string;
}
interface DocumentTocProps {
entries: TocEntry[];
visible: boolean;
onJump: (id: string) => void;
}
export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
if (!visible || entries.length === 0) {
return null;
}
return (
<div className="pointer-events-none absolute right-0 top-0 z-10 hidden lg:block">
<div className="pointer-events-auto mt-2 w-48 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
<div className="mb-2 text-[11px] font-semibold text-gray-400"></div>
<ul className="space-y-1">
{entries.map((entry) => (
<li key={entry.id}>
<button
type="button"
className={cn(
"w-full rounded-md px-2 py-1 text-left text-[11px] text-gray-500 transition-colors hover:bg-[#eef2ff] hover:text-[#2563eb]",
entry.level > 1 && "pl-4",
entry.level > 2 && "pl-6",
)}
onClick={() => onJump(entry.id)}
>
<span className="mr-2 font-mono text-[10px] text-gray-400">{entry.numbering}</span>
{entry.title || "未命名"}
</button>
</li>
))}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,335 @@
'use client';
import { useCallback, useMemo } from "react";
import type { Block, PartialBlock } from "@blocknote/core";
import {
BlockColorsItem,
SideMenu,
TableColumnHeaderItem,
TableRowHeaderItem,
useBlockNoteEditor,
useComponentsContext,
type DragHandleMenuProps,
type SideMenuProps,
} from "@blocknote/react";
import { useRouter } from "next/navigation";
import type { CustomBlockSchema } from "../schema";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
typeof TableRowHeaderItem
>[0]["block"];
type DraftBlock = PartialBlock<CustomBlockSchema> & { id?: string };
type ConvertOption = {
label: string;
type?: Block<CustomBlockSchema>["type"];
props?: Record<string, unknown>;
shortcut?: string;
action?: () => void;
};
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
};
const extractText = (block: Block<CustomBlockSchema>) => {
const inlineNodes = block.content as InlineNode[] | undefined;
const maybeText = inlineNodes?.[0]?.text;
if (typeof maybeText === "string" && maybeText.trim().length > 0) {
return maybeText.trim();
}
return "未命名页面";
};
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<CustomBlockSchema>();
const router = useRouter();
const duplicateBlock = useCallback(() => {
const blockWithoutId: DraftBlock = { ...block };
delete blockWithoutId.id;
editor.insertBlocks([blockWithoutId], block, "after");
}, [block, editor]);
const removePageReference = useCallback(async () => {
if (block.type === "pageReference") {
const pageId = block.props.pageId;
if (pageId) {
await fetch("/api/documents/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId: pageId }),
});
}
}
editor.removeBlocks([block.id]);
router.refresh();
}, [block, editor, router]);
const handleDeleteBlock = useCallback(() => {
if (block.type === "pageReference") {
void removePageReference();
return;
}
editor.removeBlocks([block.id]);
}, [block, editor, removePageReference]);
const turnToPage = useCallback(async () => {
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: currentDocumentId,
title: extractText(block),
blocks: [block],
}),
});
if (!response.ok) {
return;
}
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]);
const moveOrEmbedBlock = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
const targetParent = window.prompt("输入目标页面 ID(将在该页面末尾插入新子页面)", currentDocumentId);
if (!targetParent) {
return;
}
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: targetParent.trim(),
title: extractText(block),
blocks: [block],
}),
});
if (!response.ok) {
window.alert("移动失败,请确认页面 ID");
return;
}
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]);
const convertOptions = useMemo<ConvertOption[]>(
() => [
{ label: "文本", type: "paragraph", shortcut: "Ctrl+Alt+0" },
{ label: "待办列表", type: "checkListItem", shortcut: "Ctrl+Shift+5" },
{ label: "高级待办列表", type: "advancedTodo" },
{ label: "主标题", type: "heading", props: { level: 1 }, shortcut: "Ctrl+Shift+1" },
{ label: "大标题", type: "heading", props: { level: 2 }, shortcut: "Ctrl+Shift+2" },
{ label: "中标题", type: "heading", props: { level: 3 }, shortcut: "Ctrl+Shift+3" },
{ label: "小标题", type: "heading", props: { level: 4 }, shortcut: "Ctrl+Shift+4" },
{ label: "页面", action: turnToPage },
{ label: "列表", type: "bulletListItem", shortcut: "Ctrl+Shift+6" },
{ label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" },
{ label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" },
{ label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } },
{ label: "引述文字", type: "blockquote" },
{ label: "代码片段", type: "codeBlock" },
],
[turnToPage],
);
const convertBlock = useCallback(
(option: ConvertOption) => {
if (option.action) {
option.action();
return;
}
if (!option.type) return;
editor.updateBlock(block, {
type: option.type,
props: option.props ?? {},
});
},
[block, editor],
);
const copyBlockLink = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
const url = `${window.location.origin}/documents/${currentDocumentId}#block-${block.id}`;
try {
await navigator.clipboard.writeText(url);
window.alert("块链接已复制");
} catch {
window.prompt("复制失败,请手动复制", url);
}
}, [block.id, currentDocumentId]);
const openOnRight = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const url = `${window.location.origin}/documents/${currentDocumentId}?preview=sidebar&focus=${block.id}`;
window.open(url, "_blank", "noopener,noreferrer");
}, [block.id, currentDocumentId]);
const setAdvancedTodoStatus = useCallback(
(status: "todo" | "doing" | "done" | "cancelled") => {
if (block.type !== "advancedTodo") return;
editor.updateBlock(block, {
props: { status },
});
},
[block, editor],
);
const toggleProgressMode = useCallback(() => {
if (block.type !== "progressMeter") return;
editor.updateBlock(block, {
props: { auto: !block.props.auto },
});
}, [block, editor]);
const setManualProgress = useCallback(() => {
if (block.type !== "progressMeter") return;
const value = Number.parseInt(window.prompt("手动设置进度(0-100", String(block.props.percent ?? 0)) ?? "", 10);
if (Number.isNaN(value)) return;
const clamped = Math.min(100, Math.max(0, value));
editor.updateBlock(block, {
props: { percent: clamped },
});
}, [block, editor]);
return (
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
<Components.Generic.Menu.Item className="bn-menu-item" onClick={openOnRight}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Root sub>
<Components.Generic.Menu.Trigger sub>
<Components.Generic.Menu.Item className="bn-menu-item" subTrigger>
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown sub className="bn-menu-dropdown">
{convertOptions.map((option) => (
<Components.Generic.Menu.Item
key={option.label}
className="bn-menu-item flex items-center justify-between gap-4"
onClick={() => convertBlock(option)}
>
<span>{option.label}</span>
{option.shortcut && <span className="text-[10px] text-gray-400">{option.shortcut}</span>}
</Components.Generic.Menu.Item>
))}
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={duplicateBlock}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={copyBlockLink}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={moveOrEmbedBlock}>
/...
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => window.alert("块历史功能开发中,敬请期待")}
>
...
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => window.alert("评论功能暂未开放")}
>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={handleDeleteBlock}>
</Components.Generic.Menu.Item>
<BlockColorsItem block={block}></BlockColorsItem>
<TableRowHeaderItem block={block as TableMenuBlock}></TableRowHeaderItem>
<TableColumnHeaderItem block={block as TableMenuBlock}></TableColumnHeaderItem>
{block.type === "advancedTodo" && (
<>
<Components.Generic.Menu.Divider className="bn-menu-divider" />
{[
{ label: "设为未开始", status: "todo" as const },
{ label: "设为进行中", status: "doing" as const },
{ label: "设为已完成", status: "done" as const },
{ label: "设为取消", status: "cancelled" as const },
].map((item) => (
<Components.Generic.Menu.Item
key={item.status}
className="bn-menu-item"
onClick={() => setAdvancedTodoStatus(item.status)}
>
{item.label}
</Components.Generic.Menu.Item>
))}
</>
)}
{block.type === "progressMeter" && (
<>
<Components.Generic.Menu.Divider className="bn-menu-divider" />
<Components.Generic.Menu.Item className="bn-menu-item" onClick={toggleProgressMode}>
{block.props.auto ? "切换为手动进度" : "切换为自动进度"}
</Components.Generic.Menu.Item>
{!block.props.auto && (
<Components.Generic.Menu.Item className="bn-menu-item" onClick={setManualProgress}>
</Components.Generic.Menu.Item>
)}
</>
)}
</Components.Generic.Menu.Dropdown>
);
};
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
};
export const CustomSideMenu = (props: CustomSideMenuProps) => (
<SideMenu
{...props}
dragHandleMenu={(dragProps) => (
<CustomDragHandleMenu
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
currentDocumentId={props.currentDocumentId}
/>
)}
/>
);
@@ -0,0 +1,343 @@
"use client";
import { useCallback, useMemo } from "react";
import type { JSX } from "react";
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
type DefaultReactSuggestionItem,
} from "@blocknote/react";
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
import { useRouter } from "next/navigation";
import {
FileImage,
FilePlus2,
FileVideo,
ListTree,
Music,
Paperclip,
PilcrowSquare,
Play,
Sparkles,
SquareCheckBig,
} from "lucide-react";
import type { CustomBlockSchema } from "../schema";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind, MediaSelection } from "@/types/media";
type Props = {
editor: BlockNoteEditor<CustomBlockSchema>;
currentDocumentId: string;
};
const matchKeywords = (query: string, aliases: string[]) => {
const lower = query.trim().toLowerCase();
if (!lower) return true;
return aliases.some((alias) => alias.toLowerCase().includes(lower));
};
const GROUP_TRANSLATIONS: Record<string, string> = {
"Headings": "标题",
"Subheadings": "副标题",
"Basic blocks": "基础块",
"Advanced": "高级",
"Media": "媒体",
"Others": "其他",
};
const DEFAULT_ITEM_TRANSLATIONS: Record<
string,
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
> = {
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
};
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
audio: <Music className="h-4 w-4 text-[#10b981]" />,
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
};
const HEADING_PRESETS = [
{
level: 1,
title: "主标题",
subtext: "适合页面名称/顶层章节",
aliases: ["biaoti1", "h1", "level1"],
},
{
level: 2,
title: "大标题",
subtext: "用于章节逻辑层",
aliases: ["biaoti2", "h2", "level2"],
},
{
level: 3,
title: "中标题",
subtext: "用于小节和段落",
aliases: ["biaoti3", "h3", "level3"],
},
{
level: 4,
title: "小标题",
subtext: "更细的结构说明",
aliases: ["biaoti4", "h4", "level4"],
},
{
level: 5,
title: "极小标题",
subtext: "适合脚注/补充说明",
aliases: ["biaoti5", "h5", "level5"],
},
];
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
const maybeKey = (item as { key?: string }).key ?? "";
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
return true;
}
const title = item.title ?? "";
return title.includes("标题");
};
export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const defaultItems = useMemo(() => getDefaultReactSlashMenuItems(editor), [editor]);
const router = useRouter();
const { openPicker } = useImagePicker();
const getItems = useCallback(
async (query: string) => {
const cursor = editor.getTextCursorPosition();
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[0];
const createPageItem: DefaultReactSuggestionItem = {
title: "嵌入页面",
group: "嵌入",
aliases: ["page", "ym", "子页面", "嵌入页面块"],
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
onItemClick: async () => {
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: currentDocumentId,
title: cursor?.block?.content?.[0]?.text ?? "未命名页面",
blocks: cursor ? [cursor.block] : [],
}),
});
if (!response.ok) return;
const { pageId, title } = await response.json();
editor.insertBlocks(
[
{
type: "pageReference",
props: { pageId, title },
},
],
referenceBlock,
"after",
);
router.refresh();
},
};
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
title: preset.title,
group: "标题",
subtext: preset.subtext,
aliases: preset.aliases,
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "heading",
props: { level: preset.level },
},
],
referenceBlock,
"after",
);
},
}));
const foldHeading: DefaultReactSuggestionItem = {
title: "折叠标题",
group: "标题",
aliases: ["toggle", "zd", "fold"],
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "heading",
props: { level: 2, isToggleable: true },
},
],
referenceBlock,
"after",
);
},
};
const advancedTodo: DefaultReactSuggestionItem = {
title: "高级待办",
group: "待办",
subtext: "四态状态 · Alt 直接取消",
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "advancedTodo",
props: { status: "todo" },
},
],
referenceBlock,
"after",
);
},
};
const progressMeter: DefaultReactSuggestionItem = {
title: "进度条",
group: "进度",
subtext: "自动读取下方待办完成度",
aliases: ["jdt", "progress", "jindu"],
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "progressMeter",
props: { percent: 0, auto: true },
},
],
referenceBlock,
"after",
);
},
};
const foldAdvancedTodo: DefaultReactSuggestionItem = {
title: "折叠高级待办",
group: "待办",
aliases: ["zdgjdb", "foldtodo"],
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "advancedTodo",
props: { status: "todo" },
},
],
referenceBlock,
"after",
);
},
};
const customItems = [
...headingItems,
foldHeading,
createPageItem,
advancedTodo,
foldAdvancedTodo,
progressMeter,
].filter((item) => matchKeywords(query, item.aliases ?? []));
const insertMediaSelection = (selection: MediaSelection) => {
editor.insertBlocks(
[
{
type: "media",
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? "image",
fileName: selection.fileName ?? "",
fileSize: selection.fileSize ?? null,
mimeType: selection.mimeType ?? "",
ocrStatus: "idle",
},
},
],
referenceBlock,
"after",
);
};
const handleMediaPick = (mediaType: MediaKind) => {
openPicker({
mediaType,
onSelect: (selection) => {
insertMediaSelection({
...selection,
assetType: selection.assetType ?? mediaType,
});
},
});
};
const localizedDefaults = defaultItems.map((item) => {
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
const next: DefaultReactSuggestionItem = { ...item };
if (translation?.title) next.title = translation.title;
if (translation?.subtext) next.subtext = translation.subtext;
if (translation?.aliases) next.aliases = translation.aliases;
if (translation?.group) {
next.group = translation.group;
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
next.group = GROUP_TRANSLATIONS[item.group];
}
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
const mediaType = item.title.toLowerCase() as MediaKind;
next.icon = MEDIA_ICONS[mediaType];
next.group = translation?.group ?? "媒体";
next.subtext = translation?.subtext ?? next.subtext;
next.aliases = translation?.aliases ?? next.aliases;
next.onItemClick = () => handleMediaPick(mediaType);
}
return next;
});
const sanitizedDefaults = localizedDefaults.filter(
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
);
const merged = [...customItems, ...sanitizedDefaults];
return filterSuggestionItems(merged, query);
},
[currentDocumentId, defaultItems, editor, openPicker, router],
);
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
}
@@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useBacklinks } from "@/hooks/use-backlinks";
import type { BacklinkRecord } from "@/types/references";
import { cn } from "@/lib/utils";
interface PageBacklinksPanelProps {
workspaceId: string;
documentId: string;
className?: string;
}
const formatRelative = (value: string) => {
if (!value) return "";
const date = new Date(value);
return date.toLocaleString();
};
const EmptyState = () => (
<div className="rounded-xl border border-dashed border-[#e2e8f0] bg-white/60 px-4 py-6 text-center text-sm text-gray-500">
<code className="rounded bg-gray-100 px-1">[[</code> <code className="rounded bg-gray-100 px-1">#</code>{" "}
</div>
);
const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
<div className="rounded-xl border border-[#f1f5f9] bg-white p-4 shadow-sm">
<div className="flex items-center justify-between text-sm font-medium text-gray-900">
<span>{record.alias || record.sourceTitle || "无标题"}</span>
<span className="text-xs text-gray-400">{record.displayMode === "embed" ? "嵌入块" : "行内引用"}</span>
</div>
<div className="mt-1 text-xs text-gray-500">
{record.sourceTitle || "无标题"} · {formatRelative(record.updatedAt)}
</div>
</div>
);
export function PageBacklinksPanel({ workspaceId, documentId, className }: PageBacklinksPanelProps) {
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
workspaceId,
documentId,
});
const records = useMemo(() => data ?? [], [data]);
if (!isLoading && !error && records.length === 0) {
return null;
}
return (
<section className={cn("rounded-2xl border border-[#eef2ff] bg-[#fdfdff] p-5 shadow-sm", className)}>
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-base font-semibold text-[#1f2933]"></p>
<p className="text-xs text-gray-500"></p>
</div>
<Button size="sm" variant="ghost" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "刷新中..." : "刷新"}
</Button>
</div>
{isLoading ? (
<div className="py-6 text-center text-sm text-gray-500">...</div>
) : error ? (
<div className="py-6 text-center text-sm text-red-500">{(error as Error).message}</div>
) : records.length === 0 ? (
<EmptyState />
) : (
<div className="space-y-3">
{records.map((record) => (
<BacklinkItem key={record.id} record={record} />
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,228 @@
"use client";
import { useState, type ComponentType } from "react";
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import { DocumentTaskPanel } from "@/components/document-task-panel";
import { Button } from "@/components/ui/button";
type TabId = "page" | "custom" | "global";
const TABS: Array<{ id: TabId; label: string }> = [
{ id: "page", label: "页面选项" },
{ id: "custom", label: "自定义页面" },
{ id: "global", label: "全局选项" },
];
const OPTION_META: Record<
keyof PageOptionsState,
{ label: string; description: string; icon: ComponentType<{ className?: string }> }
> = {
wideLayout: {
label: "自适应宽度",
description: "让编辑区域根据屏幕自动铺满",
icon: Maximize2,
},
smallText: {
label: "小字体",
description: "使用更紧凑的字号排版",
icon: Type,
},
showHeadingNumbers: {
label: "标题编号",
description: "自动为标题添加编号",
icon: ListOrdered,
},
showToc: {
label: "显示目录",
description: "在右侧展示目录导航",
icon: ListTree,
},
showStructure: {
label: "块结构线框",
description: "显示块级元素的结构边界",
icon: Focus,
},
protectEditing: {
label: "编辑保护",
description: "保护内容避免误触修改",
icon: ShieldCheck,
},
showWordCount: {
label: "字数提示",
description: "实时展示字数和块统计",
icon: BookOpenCheck,
},
};
const CUSTOM_LAYOUT_OPTIONS: (keyof PageOptionsState)[] = ["wideLayout", "smallText"];
const CUSTOM_STRUCTURE_OPTIONS: (keyof PageOptionsState)[] = ["showHeadingNumbers", "showToc"];
const GLOBAL_OPTIONS: (keyof PageOptionsState)[] = ["showStructure", "protectEditing", "showWordCount"];
interface PageOptionsSidebarProps {
documentId: string;
options: PageOptionsState;
stats?: DocumentStats;
onToggle: (key: keyof PageOptionsState) => void;
onExport: () => void;
onOpenHistory: () => void;
}
export function PageOptionsSidebar({
documentId,
options,
stats,
onToggle,
onExport,
onOpenHistory,
}: PageOptionsSidebarProps) {
const [activeTab, setActiveTab] = useState<TabId>("page");
return (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[#f0f0f0] bg-white/95">
<div className="flex border-b border-[#f5f5f5]">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
"flex-1 border-b-2 px-4 py-3 text-sm font-medium text-gray-500",
activeTab === tab.id ? "border-[#2563eb] text-[#2563eb]" : "border-transparent hover:text-gray-700",
)}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "page" && (
<div className="space-y-4">
{options.showWordCount && stats && (
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-gray-500">
<StatsCell label="字数" value={stats.wordCount} />
<StatsCell label="字符" value={stats.characterCount} />
<StatsCell label="块数" value={stats.blockCount} />
</div>
</section>
)}
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800"></span>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={onExport}>
</Button>
<Button type="button" variant="outline" size="sm" onClick={onOpenHistory}>
</Button>
</div>
</div>
<p className="mt-2 text-xs text-gray-400"></p>
</section>
<DocumentTaskPanel documentId={documentId} />
</div>
)}
{activeTab === "custom" && (
<div className="space-y-5">
<OptionToggleGroup
title="布局与排版"
optionKeys={CUSTOM_LAYOUT_OPTIONS}
options={options}
onToggle={onToggle}
/>
<OptionToggleGroup
title="结构与目录"
optionKeys={CUSTOM_STRUCTURE_OPTIONS}
options={options}
onToggle={onToggle}
/>
</div>
)}
{activeTab === "global" && (
<div className="space-y-5">
<OptionToggleGroup title="全局偏好" optionKeys={GLOBAL_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-dashed border-[#e3e3e3] p-4 text-xs text-gray-400">
Good Night
</section>
</div>
)}
</div>
</aside>
);
}
function OptionToggleGroup({
title,
optionKeys,
options,
onToggle,
}: {
title: string;
optionKeys: (keyof PageOptionsState)[];
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
}) {
return (
<section>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-400">{title}</div>
<div className="mt-3 space-y-2">
{optionKeys.map((key) => (
<OptionToggle key={key} optionKey={key} options={options} onToggle={onToggle} />
))}
</div>
</section>
);
}
function OptionToggle({
optionKey,
options,
onToggle,
}: {
optionKey: keyof PageOptionsState;
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
}) {
const meta = OPTION_META[optionKey];
const Icon = meta.icon;
const active = options[optionKey];
return (
<button
type="button"
className="flex w-full items-center justify-between rounded-2xl border border-transparent bg-[#f9fafc] px-3 py-2 text-left shadow-sm transition hover:border-[#dbe7ff]"
onClick={() => onToggle(optionKey)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"rounded-xl border p-2",
active ? "border-[#cddfff] bg-[#eef4ff]" : "border-transparent bg-white",
)}
>
<Icon className={cn("h-4 w-4", active ? "text-[#2563eb]" : "text-gray-500")} />
</div>
<div>
<div className="text-sm font-medium text-gray-900">{meta.label}</div>
<div className="text-xs text-gray-400">{meta.description}</div>
</div>
</div>
<span className={cn("text-xs font-semibold", active ? "text-[#2563eb]" : "text-gray-400")}>
{active ? "已开启" : "已关闭"}
</span>
</button>
);
}
function StatsCell({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-xl bg-white py-3 text-center shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900">{value}</div>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { ImagePickerDialog, type PickerTab } from "@/components/media/image-picker-dialog";
import type { MediaKind, MediaSelection } from "@/types/media";
interface PickerState {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
onSelect?: (selection: MediaSelection) => void;
}
interface ImagePickerContextValue {
openPicker: (options: { onSelect: (selection: MediaSelection) => void; defaultTab?: PickerTab; mediaType?: MediaKind }) => void;
}
const ImagePickerContext = createContext<ImagePickerContextValue | undefined>(undefined);
interface ImagePickerProviderProps {
documentId: string;
workspaceId: string;
children: ReactNode;
}
export function ImagePickerProvider({ documentId, workspaceId, children }: ImagePickerProviderProps) {
const [state, setState] = useState<PickerState>({
open: false,
defaultTab: "upload",
mediaType: "image",
});
const contextValue = useMemo<ImagePickerContextValue>(
() => ({
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image" }) => {
setState({
open: true,
onSelect,
defaultTab,
mediaType,
});
},
}),
[],
);
const handleClose = () => {
setState((prev) => ({ ...prev, open: false }));
};
const handleSelect = (selection: MediaSelection) => {
state.onSelect?.(selection);
setState((prev) => ({ ...prev, open: false }));
};
return (
<ImagePickerContext.Provider value={contextValue}>
{children}
<ImagePickerDialog
open={state.open}
defaultTab={state.defaultTab}
mediaType={state.mediaType}
documentId={documentId}
workspaceId={workspaceId}
onClose={handleClose}
onSelect={handleSelect}
/>
</ImagePickerContext.Provider>
);
}
export function useImagePicker() {
const context = useContext(ImagePickerContext);
if (!context) {
throw new Error("useImagePicker 必须在 ImagePickerProvider 中使用");
}
return context;
}
@@ -0,0 +1,392 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { MediaAsset, MediaKind, MediaSelection } from "@/types/media";
export type PickerTab = "upload" | "recent" | "link" | "icon";
interface ImagePickerDialogProps {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
documentId: string;
workspaceId: string;
onClose: () => void;
onSelect: (selection: MediaSelection) => void;
}
const ICON_PRESETS: Array<{ id: string; label: string; url: string }> = [
{ id: "progress", label: "进度波纹", url: "https://media.giphy.com/media/QBd2kLB5qDmysEXre9/giphy.gif" },
{ id: "heartbeat", label: "心跳", url: "https://media.giphy.com/media/HEPwfdu6T6svpPE1eN/giphy.gif" },
{ id: "sparkles", label: "闪光", url: "https://media.giphy.com/media/f9jQLaPmuFKfa7E55Q/giphy.gif" },
{ id: "loading", label: "加载", url: "https://media.giphy.com/media/52qtwCtj9OLTi/giphy.gif" },
];
const MEDIA_TABS: Record<MediaKind, PickerTab[]> = {
image: ["upload", "recent", "link", "icon"],
video: ["upload", "recent", "link"],
audio: ["upload", "recent", "link"],
file: ["upload", "recent", "link"],
};
const TAB_LABELS: Record<PickerTab, string> = {
upload: "上传",
recent: "最近",
link: "链接",
icon: "动态图标",
};
const MEDIA_ACCEPTS: Record<MediaKind, Record<string, string[]> | null> = {
image: { "image/*": [] },
video: { "video/*": [] },
audio: { "audio/*": [] },
file: null,
};
const MEDIA_TITLES: Record<MediaKind, string> = {
image: "选择图片",
video: "选择视频",
audio: "选择音频",
file: "选择文件",
};
const formatBytes = (size?: number | null) => {
if (!size || size <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let value = size;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[index]}`;
};
const guessFileNameFromUrl = (url: string) => {
try {
const parsed = new URL(url);
const name = parsed.pathname.split("/").filter(Boolean).pop();
if (name) {
return decodeURIComponent(name);
}
} catch {
const segments = url.split("?")[0]?.split("/") ?? [];
const name = segments.pop();
if (name) {
return decodeURIComponent(name);
}
}
return "未命名资源";
};
export function ImagePickerDialog({
open,
defaultTab,
mediaType,
documentId,
workspaceId,
onClose,
onSelect,
}: ImagePickerDialogProps) {
const [tab, setTab] = useState<PickerTab>(defaultTab);
const availableTabs = MEDIA_TABS[mediaType];
const [recent, setRecent] = useState<MediaAsset[]>([]);
const [recentLoading, setRecentLoading] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
const availableTabs = MEDIA_TABS[mediaType];
const fallback = availableTabs.includes(defaultTab) ? defaultTab : availableTabs[0];
setTab(fallback);
}
}, [defaultTab, mediaType, open]);
const fetchRecent = useCallback(async () => {
if (!workspaceId) {
return;
}
setRecentLoading(true);
try {
const response = await fetch(
`/api/media/assets?workspaceId=${workspaceId}&assetType=${mediaType}`,
);
if (!response.ok) {
throw new Error("无法获取最近上传");
}
const payload = (await response.json()) as { items: MediaAsset[] };
setRecent(payload.items ?? []);
} catch (err) {
console.error(err);
setRecent([]);
} finally {
setRecentLoading(false);
}
}, [workspaceId, mediaType]);
useEffect(() => {
if (open && tab === "recent") {
void fetchRecent();
}
}, [open, tab, fetchRecent]);
const handleUpload = useCallback(
async (file: File) => {
if (!workspaceId || !documentId) {
setError("缺少必要参数");
return;
}
setUploading(true);
setError(null);
try {
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
onClose();
} catch (err) {
setError((err as Error).message);
} finally {
setUploading(false);
}
},
[documentId, onClose, onSelect, workspaceId],
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
void handleUpload(acceptedFiles[0]);
},
[handleUpload],
);
const acceptConfig = MEDIA_ACCEPTS[mediaType];
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptConfig ?? undefined,
maxFiles: 1,
});
const handleLinkSubmit = async () => {
if (!linkUrl.trim()) return;
try {
const targetUrl = linkUrl.trim();
const response = await fetch("/api/media/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId,
documentId,
fileUrl: targetUrl,
thumbnailUrl: targetUrl,
assetType: mediaType,
fileName: guessFileNameFromUrl(targetUrl),
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "保存失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("缺少图片地址");
}
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
setLinkUrl("");
onClose();
} catch (err) {
setError((err as Error).message);
}
};
const renderRecent = () => {
if (recentLoading) {
return <div className="py-8 text-center text-sm text-gray-500">...</div>;
}
if (recent.length === 0) {
return <div className="py-8 text-center text-sm text-gray-400">{MEDIA_TITLES[mediaType].replace("选择", "")}</div>;
}
return (
<div className="grid grid-cols-3 gap-3">
{recent.map((asset) => (
<button
type="button"
key={asset.id}
className="rounded-xl border border-gray-200 bg-white p-2 text-left shadow-sm transition hover:border-[#2563eb]"
onClick={() => {
if (!asset.file_url) return;
onSelect({
assetId: asset.id,
fileUrl: asset.file_url,
thumbnailUrl: asset.thumbnail_url,
assetType: asset.asset_type,
fileName: asset.file_name,
fileSize: asset.file_size,
mimeType: asset.mime_type,
});
onClose();
}}
>
{mediaType === "image" ? (
<img
src={asset.thumbnail_url ?? asset.file_url ?? ""}
alt={asset.asset_type}
className="h-32 w-full rounded-lg object-cover"
/>
) : (
<div className="flex h-32 flex-col items-start justify-center gap-1 rounded-lg bg-gray-50 px-3">
<p className="text-sm font-medium text-gray-700">{asset.file_name ?? "未命名资源"}</p>
{asset.file_size ? <p className="text-xs text-gray-500">{formatBytes(asset.file_size)}</p> : null}
</div>
)}
<p className="mt-2 truncate text-xs text-gray-500">
{asset.asset_type === "icon" ? "动态图标" : MEDIA_TITLES[mediaType].replace("选择", "")}
</p>
</button>
))}
</div>
);
};
const renderIcons = () => (
<div className="grid grid-cols-2 gap-3">
{ICON_PRESETS.map((icon) => (
<button
key={icon.id}
type="button"
className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 p-3 text-sm text-gray-600 transition hover:border-[#2563eb]"
onClick={() => {
onSelect({
assetId: `icon-${icon.id}`,
fileUrl: icon.url,
thumbnailUrl: icon.url,
assetType: "icon",
});
onClose();
}}
>
<img src={icon.url} alt={icon.label} className="h-24 w-24 rounded-lg object-cover" />
<span className="mt-2 text-xs">{icon.label}</span>
</button>
))}
</div>
);
const helperText = useMemo(() => {
const label = MEDIA_TITLES[mediaType].replace("选择", "");
switch (tab) {
case "upload":
return `支持拖拽或点击上传${label},单个文件不超过 200MB`;
case "recent":
return `最近 12 个${label},支持一键选取`;
case "link":
return `输入${label}直链或 CDN 地址`;
case "icon":
return "选择 Wolai 常用动态图标";
default:
return "";
}
}, [mediaType, tab]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader className="text-left">
<DialogTitle>{MEDIA_TITLES[mediaType]}</DialogTitle>
<DialogDescription>{helperText}</DialogDescription>
</DialogHeader>
{error && <div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>}
<Tabs value={tab} onValueChange={(value) => setTab(value as PickerTab)}>
<TabsList className="mb-4">
{availableTabs.map((tabKey) => (
<TabsTrigger key={tabKey} value={tabKey}>
{TAB_LABELS[tabKey]}
</TabsTrigger>
))}
</TabsList>
{availableTabs.includes("upload") && (
<TabsContent value="upload">
<div
{...getRootProps()}
className={cn(
"flex h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-300 text-sm text-gray-500",
isDragActive && "border-[#2563eb] bg-[#f5f7ff]",
)}
>
<input {...getInputProps()} />
{uploading ? "上传中..." : "拖拽文件到此处,或点击选择文件"}
</div>
</TabsContent>
)}
{availableTabs.includes("recent") && (
<TabsContent value="recent">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-gray-500">{MEDIA_TITLES[mediaType].replace("选择", "")}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => void fetchRecent()}>
</Button>
</div>
{renderRecent()}
</TabsContent>
)}
{availableTabs.includes("link") && (
<TabsContent value="link">
<div className="flex gap-2">
<Input
value={linkUrl}
onChange={(event) => setLinkUrl(event.target.value)}
placeholder={`输入${MEDIA_TITLES[mediaType].replace("选择", "")}链接,例如 https://example.com/file`}
/>
<Button type="button" onClick={() => void handleLinkSubmit()}>
</Button>
</div>
</TabsContent>
)}
{availableTabs.includes("icon") && <TabsContent value="icon">{renderIcons()}</TabsContent>}
</Tabs>
</DialogContent>
</Dialog>
);
}
View File
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { Menu } from "lucide-react";
import { useSidebarStore } from "@/store/sidebar";
export function MobileSidebarTrigger() {
const setOpen = useSidebarStore((state) => state.setOpen);
return (
<button
type="button"
aria-label="展开侧边栏"
onClick={() => setOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[#eeeeee] text-gray-600 md:hidden"
>
<Menu className="h-4 w-4" />
</button>
);
}
@@ -0,0 +1,31 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
@@ -0,0 +1,18 @@
"use client";
import type { Session } from "@supabase/supabase-js";
import { SessionContextProvider } from "@supabase/auth-helpers-react";
import { supabaseBrowser } from "@/lib/supabase/client";
interface SupabaseProviderProps {
session: Session | null;
children: React.ReactNode;
}
export function SupabaseProvider({ session, children }: SupabaseProviderProps) {
return (
<SessionContextProvider supabaseClient={supabaseBrowser} initialSession={session}>
{children}
</SessionContextProvider>
);
}
@@ -0,0 +1,39 @@
import { Button } from "@/components/ui/button";
import type { DocumentSearchResult } from "@/types/search";
interface PageHoverCardProps {
result: DocumentSearchResult;
onPreview?: () => void;
}
const formatRelative = (value: string | null) => {
if (!value) return "未知时间";
const date = new Date(value);
return date.toLocaleString();
};
export function PageHoverCard({ result, onPreview }: PageHoverCardProps) {
return (
<div className="space-y-2 text-xs text-gray-600">
<div>
<p className="text-sm font-semibold text-gray-900">{result.title || "无标题"}</p>
<p className="text-[11px] text-gray-400">
{formatRelative(result.updatedAt)} · {formatRelative(result.createdAt)}
</p>
</div>
<div
className="line-clamp-4 rounded-lg bg-[#f8fafc] p-2 text-[11px] leading-relaxed text-gray-600"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
<Button
type="button"
size="sm"
variant="outline"
className="w-full text-[11px]"
onClick={() => onPreview?.()}
>
</Button>
</div>
);
}
+654
View File
@@ -0,0 +1,654 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DragEvent as ReactDragEvent } from "react";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette";
import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useReferenceComposer } from "@/hooks/use-reference-composer";
import { recordRecentPage } from "@/lib/search/record-recent";
import { PageHoverCard } from "@/components/reference/page-hover-card";
import { buildSearchRequest } from "@/lib/search/request";
import { resolveSearchOpenMode } from "@/lib/search/shortcuts";
interface SearchPaletteProps {
workspaceId: string | null;
}
const TIME_RANGE_LABEL: Record<DocumentSearchTimeRange, string> = {
any: "全部时间",
"7d": "最近 7 天",
"30d": "最近 30 天",
};
const formatDate = (value: string | null) => {
if (!value) return "未知时间";
const date = new Date(value);
return date.toLocaleString();
};
export function SearchPalette({ workspaceId }: SearchPaletteProps) {
const router = useRouter();
const segments = useSelectedLayoutSegments();
const activeDocumentId = segments?.[1] ?? null;
const inputRef = useRef<HTMLInputElement>(null);
const {
open,
mode,
query,
filters,
timeRange,
referenceMode,
alias,
recent,
setRecent,
openSearch,
openReference,
close,
setQuery,
toggleFilter,
setTimeRange,
setTimeField,
setCustomRange,
setReferenceMode,
setAlias,
rememberResult,
} = useSearchPaletteStore();
const [highlightedIndex, setHighlightedIndex] = useState(0);
const { insertReference } = useReferenceComposer({
workspaceId,
sourcePageId: activeDocumentId ?? null,
});
const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all");
const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference");
const updateCustomRange = useCallback(
(patch: { from?: string; to?: string }) => {
const next = { ...(filters.customRange ?? {}), ...patch };
if (!next.from && !next.to) {
setCustomRange(null);
} else {
setCustomRange(next);
}
},
[filters.customRange, setCustomRange],
);
const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]);
const requestPayload = useMemo<DocumentSearchRequest | null>(
() =>
buildSearchRequest({
workspaceId,
activeDocumentId,
query,
filters,
timeRange,
}),
[workspaceId, activeDocumentId, query, filters, timeRange],
);
const searchQueryEnabled = Boolean(open && workspaceId);
const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled);
useEffect(() => {
if (data?.recent) {
setRecent(data.recent);
}
}, [data?.recent, setRecent]);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (mode === "reference") {
setReferenceFiltersOpen(true);
} else {
setReferenceFiltersOpen(false);
}
}, [mode]);
/* eslint-enable react-hooks/set-state-in-effect */
const searchResults = data?.results;
const remoteResults = useMemo(() => searchResults ?? [], [searchResults]);
const showRecent = !query.trim() && recent.length > 0;
const enableRecentTab = showRecent;
const effectiveTab = enableRecentTab ? resultTab : "all";
const activeResults = effectiveTab === "recent" ? recent : remoteResults;
useEffect(() => {
if (!open) {
return undefined;
}
inputRef.current?.focus();
const frame = requestAnimationFrame(() => setHighlightedIndex(0));
return () => cancelAnimationFrame(frame);
}, [open]);
useEffect(() => {
if (highlightedIndex >= activeResults.length) {
const frame = requestAnimationFrame(() =>
setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0),
);
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [activeResults.length, highlightedIndex]);
useEffect(() => {
const handleGlobalHotkey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault();
openSearch();
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
event.preventDefault();
openReference();
}
};
window.addEventListener("keydown", handleGlobalHotkey);
return () => window.removeEventListener("keydown", handleGlobalHotkey);
}, [openSearch, openReference]);
const handleOpenResult = useCallback(
(result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => {
if (openMode === "new-window") {
window.open(result.publicPath, "_blank", "noopener,noreferrer");
} else if (openMode === "sidebar") {
window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer");
} else {
router.push(result.publicPath);
}
void recordRecentPage(workspaceId, result.id);
rememberResult(result);
close();
},
[router, workspaceId, rememberResult, close],
);
const handleInsertReference = useCallback(
(result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => {
const run = async () => {
try {
const effectiveMode = overrideMode ?? referenceMode;
await insertReference(result, {
mode: effectiveMode,
alias: effectiveMode === "inline" ? alias : undefined,
});
rememberResult(result);
close();
} catch (error) {
console.error(error);
}
};
void run();
},
[alias, close, insertReference, referenceMode, rememberResult],
);
const handleCopyReference = useCallback((result: DocumentSearchResult) => {
if (typeof window === "undefined") {
return;
}
const payload = `((${result.id}))`;
if (navigator?.clipboard) {
navigator.clipboard
.writeText(payload)
.then(() => window.alert("块引用已复制"))
.catch(() => {
window.prompt("复制失败,请手动复制引用内容", payload);
});
} else {
window.prompt("复制块引用", payload);
}
}, []);
useEffect(() => {
if (!open) {
return;
}
const handleKey = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
if (activeResults.length === 0) {
return;
}
const result = activeResults[highlightedIndex] ?? activeResults[0];
if (!result) return;
if (mode === "search") {
const openMode = resolveSearchOpenMode(event);
handleOpenResult(result, openMode);
} else {
handleInsertReference(result);
}
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]);
const pending = isLoading || isFetching;
const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板";
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-hidden border-none bg-white/95 p-0 shadow-xl">
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
<div className="flex h-[520px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
ref={inputRef}
value={query}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue.trim().length > 0 && resultTab !== "all") {
setResultTab("all");
}
setQuery(nextValue);
}}
placeholder={mode === "search" ? "搜索页面标题、正文或 OCR 内容..." : "选择要引用的页面"}
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 text-xs text-gray-600">
<CalendarClock className="h-4 w-4" />
{TIME_RANGE_LABEL[timeRange]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => (
<DropdownMenuItem
key={range}
onClick={() => setTimeRange(range)}
className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")}
>
{TIME_RANGE_LABEL[range]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="text-xs text-gray-600">
{filters.timeField === "updated" ? "按编辑时间" : "按创建时间"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={() => setTimeField("updated")}
className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTimeField("created")}
className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<FilterToggle label="仅标题" active={filters.titleOnly} onClick={() => toggleFilter("titleOnly")} />
<FilterToggle label="精确匹配" active={filters.exact} onClick={() => toggleFilter("exact")} />
<FilterToggle
label="当前页面"
active={filters.onlyCurrentPage && Boolean(activeDocumentId)}
disabled={!activeDocumentId}
onClick={() => toggleFilter("onlyCurrentPage")}
/>
<FilterToggle
label="图片 OCR"
active={filters.includeOcr}
onClick={() => toggleFilter("includeOcr")}
/>
{mode === "reference" && (
<Button
type="button"
size="sm"
variant="ghost"
className="ml-auto flex items-center gap-1 rounded-full text-xs text-gray-600"
onClick={() => setReferenceFiltersOpen((prev) => !prev)}
>
<ChevronsUpDown className="h-3 w-3" />
{referenceFiltersOpen ? "隐藏引用筛选" : "展开引用筛选"}
</Button>
)}
</div>
{mode === "reference" && referenceFiltersOpen && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<ReferenceModeToggle
label="行内引用"
active={referenceMode === "inline"}
onClick={() => setReferenceMode("inline")}
/>
<ReferenceModeToggle
label="嵌入块"
active={referenceMode === "embed"}
onClick={() => setReferenceMode("embed")}
/>
{referenceMode === "inline" && (
<Input
value={alias}
onChange={(event) => setAlias(event.target.value)}
placeholder="引用别名"
className="h-8 w-40 text-xs"
/>
)}
<span className="text-[11px] text-gray-400"> [[ / # </span>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span></span>
<Input
type="date"
value={filters.customRange?.from ?? ""}
onChange={(event) => updateCustomRange({ from: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Input
type="date"
value={filters.customRange?.to ?? ""}
onChange={(event) => updateCustomRange({ to: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-gray-500"
onClick={clearCustomRange}
disabled={!filters.customRange?.from && !filters.customRange?.to}
>
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden">
{enableRecentTab && (
<div className="flex items-center gap-2 border-b border-[#eef2ff] px-4 py-2 text-xs">
<TabButton
label="最近访问"
active={resultTab === "recent"}
onClick={() => setResultTab("recent")}
disabled={!enableRecentTab}
/>
<TabButton label="全部页面" active={resultTab === "all"} onClick={() => setResultTab("all")} />
</div>
)}
{pending ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
...
</div>
) : error ? (
<div className="flex h-full items-center justify-center text-sm text-red-500">
{(error as Error).message}
</div>
) : activeResults.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
</div>
) : (
<div className="h-full divide-y divide-[#f1f5f9] overflow-y-auto">
{activeResults.map((result, index) => (
<ResultRow
key={`result-${result.id}-${index}-${resultTab}`}
result={result}
highlighted={highlightedIndex === index}
mode={mode}
onOpen={() => handleOpenResult(result, "main")}
onReference={() => handleInsertReference(result)}
onEmbed={() => handleInsertReference(result, "embed")}
onCopy={() => handleCopyReference(result)}
/>
))}
</div>
)}
</div>
<div className="border-t border-[#eef2ff] px-4 py-2 text-xs text-gray-500">
{mode === "search" ? (
<div className="flex items-center justify-between">
<span>Enter · Ctrl/Cmd+Enter · Alt+Enter </span>
<span>Esc </span>
</div>
) : (
<div className="flex items-center justify-between">
<span>
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"}
</span>
<span>Esc </span>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
function ResultRow({
result,
highlighted,
mode,
onOpen,
onReference,
onEmbed,
onCopy,
}: {
result: DocumentSearchResult;
highlighted: boolean;
mode: SearchPaletteMode;
onOpen: () => void;
onReference: () => void;
onEmbed: () => void;
onCopy: () => void;
}) {
const handleActivate = () => {
if (mode === "search") {
onOpen();
} else {
onReference();
}
};
const handleDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
if (mode !== "reference") return;
if (event.dataTransfer) {
event.dataTransfer.setData("text/plain", `((${result.id}))`);
event.dataTransfer.effectAllowed = "copy";
}
};
const content = (
<div
role="button"
tabIndex={0}
onClick={handleActivate}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleActivate();
}
}}
className={cn(
"cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]",
highlighted && "bg-[#e3ecff]",
)}
draggable={mode === "reference"}
onDragStart={handleDragStart}
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
{result.title || "无标题"}
{result.matchField !== "recent" && (
<Badge variant="secondary" className="bg-[#edf2ff] text-xs text-[#2563eb]">
{result.matchField === "title" ? "标题匹配" : "正文匹配"}
</Badge>
)}
{result.hasOcr && (
<Badge variant="outline" className="text-[10px] text-[#475569]">
OCR
</Badge>
)}
</div>
<div className="mt-1 text-xs text-gray-500" dangerouslySetInnerHTML={{ __html: result.snippet }} />
<div className="mt-2 text-[11px] text-gray-400">
{formatDate(result.updatedAt)} · {formatDate(result.createdAt)}
</div>
{mode === "reference" && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onReference();
}}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onEmbed();
}}
>
<Layers className="h-3 w-3" />
...
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCopy();
}}
>
<Copy className="h-3 w-3" />
</Button>
<span className="ml-auto text-[10px] text-gray-400"></span>
</div>
)}
</div>
);
return (
<HoverCard openDelay={250}>
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
<HoverCardContent align="start">
<PageHoverCard result={result} onPreview={onOpen} />
</HoverCardContent>
</HoverCard>
);
}
interface FilterToggleProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"h-7 rounded-full border px-3 text-xs",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface TabButtonProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function TabButton({ label, active, onClick, disabled }: TabButtonProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"rounded-full px-4 text-xs",
active ? "bg-[#2563eb] text-white" : "text-gray-600 hover:bg-[#eef2ff]",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface ReferenceModeToggleProps {
label: string;
active: boolean;
onClick: () => void;
}
function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"h-7 rounded-full border px-3 text-[11px]",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
View File
+257
View File
@@ -0,0 +1,257 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useRef, useState } from "react";
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type { DocumentNode } from "@/lib/documents";
import { cn } from "@/lib/utils";
import { flattenDocumentTree } from "@/lib/sidebar-tree";
interface PrivateTreeProps {
nodes: DocumentNode[];
expanded: Set<string>;
activeId: string;
onToggleExpand: (id: string) => void;
onMove: (nodeId: string, parentId: string | null, index: number) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
}
const ROW_HEIGHT = 36;
export function PrivateTree({
nodes,
expanded,
activeId,
onToggleExpand,
onMove,
onCreateChild,
onContextMenu,
}: PrivateTreeProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const flatNodes = useMemo(() => flattenDocumentTree(nodes, expanded), [nodes, expanded]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: flatNodes.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeId = event.active.id as string;
const overId = event.over?.id as string | undefined;
setActiveDragId(null);
if (!overId || activeId === overId) {
return;
}
const activeIndex = flatNodes.findIndex((item) => item.node.id === activeId);
const overIndex = flatNodes.findIndex((item) => item.node.id === overId);
if (activeIndex === -1 || overIndex === -1) {
return;
}
const targetParent = flatNodes[overIndex].parentId;
const siblingList = flatNodes.filter((item) => item.parentId === targetParent);
const siblingIndex = siblingList.findIndex((item) => item.node.id === overId);
const position = siblingIndex === -1 ? siblingList.length : siblingIndex;
onMove(activeId, targetParent, position);
},
[flatNodes, onMove],
);
const handleDragCancel = useCallback(() => {
setActiveDragId(null);
}, []);
if (flatNodes.length === 0) {
return <div className="px-4 py-6 text-sm text-gray-400"></div>;
}
return (
<ScrollAreaPrimitive.Root type="auto" className="relative h-full w-full">
<ScrollAreaPrimitive.Viewport ref={scrollAreaRef} className="h-full w-full">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={flatNodes.map((item) => item.node.id)} strategy={verticalListSortingStrategy}>
<div className="relative h-full">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = flatNodes[virtualRow.index];
return (
<VirtualRow key={item.node.id} virtualRow={virtualRow}>
<SortableTreeRow
node={item.node}
depth={item.depth}
expanded={expanded.has(item.node.id)}
hasChildren={item.node.children.length > 0}
activeId={activeId}
isDragging={activeDragId === item.node.id}
onToggleExpand={() => onToggleExpand(item.node.id)}
onCreateChild={() => onCreateChild(item.node.id)}
onContextMenu={(event) => onContextMenu(event, item.node)}
/>
</VirtualRow>
);
})}
</div>
</div>
</SortableContext>
</DndContext>
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation="vertical"
className="flex w-2.5 touch-none select-none border-l border-l-transparent bg-transparent px-0.5 py-2"
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[#c9d6f8]" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function VirtualRow({
children,
virtualRow,
}: {
children: React.ReactNode;
virtualRow: VirtualItem;
}) {
return (
<div
className="absolute left-0 right-0"
style={{
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
>
{children}
</div>
);
}
interface SortableTreeRowProps {
node: DocumentNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
activeId: string;
isDragging: boolean;
onToggleExpand: () => void;
onCreateChild: () => void;
onContextMenu: (event: React.MouseEvent) => void;
}
function SortableTreeRow({
node,
depth,
expanded,
hasChildren,
activeId,
isDragging,
onToggleExpand,
onCreateChild,
onContextMenu,
}: SortableTreeRowProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: node.id,
});
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex h-full items-center gap-1 border-b border-transparent px-2 text-sm text-gray-700 hover:bg-[#f5f7fb]",
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
isDragging && "opacity-60",
)}
onContextMenu={onContextMenu}
>
<button
type="button"
aria-label="拖拽排序"
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-600"
{...listeners}
{...attributes}
>
<GripVertical className="h-4 w-4" />
</button>
<div
className="flex flex-1 items-center gap-2 rounded-md px-1 py-1"
style={{ paddingLeft: depth * 14 }}
>
{hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
onClick={onToggleExpand}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-90")} />
</button>
) : (
<span className="h-5 w-5" />
)}
<Link href={`/documents/${node.id}`} className="flex-1 truncate text-left">
{node.title || "无标题"}
</Link>
</div>
<button
type="button"
aria-label="新建子页面"
onClick={onCreateChild}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="更多操作"
onClick={(event) => {
event.preventDefault();
onContextMenu(event);
}}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
export interface TrashRecord {
id: string;
title: string | null;
deleted_at: string;
parent_id: string | null;
access_scope: DocumentRecord["access_scope"];
}
export interface SidebarInitialData {
activeWorkspaceId: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
trashedDocuments: TrashRecord[];
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+184
View File
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+152
View File
@@ -0,0 +1,152 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
fullScreen = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
fullScreen?: boolean
}) {
const baseClass =
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
const defaultLayout =
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg";
const fullscreenLayout =
"fixed inset-0 z-50 grid h-full w-full translate-x-0 translate-y-0 rounded-none border-none p-0 shadow-none duration-200";
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
baseClass,
fullScreen ? fullscreenLayout : defaultLayout,
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
function HoverCardContent({
className,
align = "center",
sideOffset = 8,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Content
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-2xl border border-[#e5e7eb] bg-white/95 p-4 text-sm shadow-lg outline-none backdrop-blur",
className,
)}
{...props}
/>
);
}
export { HoverCard, HoverCardContent, HoverCardTrigger };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+139
View File
@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+47
View File
@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
View File
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
type Status = "idle" | "ok" | "error";
export function useBackendHealth() {
const [status, setStatus] = useState<Status>(() => {
if (!process.env.NEXT_PUBLIC_BACKEND_URL) {
return "error";
}
return "idle";
});
useEffect(() => {
let destroyed = false;
const url = process.env.NEXT_PUBLIC_BACKEND_URL;
if (!url) {
return;
}
const controller = new AbortController();
const check = async () => {
try {
const response = await fetch(`${url}/health`, { signal: controller.signal });
if (!destroyed) {
setStatus(response.ok ? "ok" : "error");
}
} catch {
if (!destroyed) {
setStatus("error");
}
}
};
check();
return () => {
destroyed = true;
controller.abort();
};
}, []);
return status;
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { BacklinkRecord } from "@/types/references";
interface UseBacklinksOptions {
workspaceId: string | null;
documentId: string | null;
}
interface RawBacklink {
id: string;
source_page_id: string;
source_block_id: string | null;
alias: string | null;
display_mode: string;
is_previewable: boolean;
created_at: string;
updated_at: string;
source_title: string | null;
}
const mapBacklink = (raw: RawBacklink): BacklinkRecord => ({
id: raw.id,
sourcePageId: raw.source_page_id,
sourceBlockId: raw.source_block_id,
alias: raw.alias,
displayMode: (raw.display_mode as BacklinkRecord["displayMode"]) ?? "inline",
isPreviewable: raw.is_previewable,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
sourceTitle: raw.source_title,
});
const fetchBacklinks = async (workspaceId: string, documentId: string): Promise<BacklinkRecord[]> => {
const params = new URLSearchParams({
workspaceId,
pageId: documentId,
});
const response = await fetch(`/api/references/backlinks?${params.toString()}`);
if (!response.ok) {
const message = (await response.json().catch(() => null))?.error ?? "加载引用失败";
throw new Error(message);
}
const payload = (await response.json()) as { backlinks: RawBacklink[] };
return (payload.backlinks ?? []).map(mapBacklink);
};
export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) {
return useQuery({
queryKey: ["page-backlinks", workspaceId, documentId],
queryFn: () => {
if (!workspaceId || !documentId) {
throw new Error("缺少引用参数");
}
return fetchBacklinks(workspaceId, documentId);
},
enabled: Boolean(workspaceId && documentId),
staleTime: 15_000,
});
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
type Procedure<T extends unknown[]> = (...args: T) => void;
type DebouncedProcedure<T extends unknown[]> = Procedure<T> & { cancel: () => void };
/**
* 用于本地输入的防抖工具,默认等待 800ms 再触发回调。
*/
export const useDebouncedCallback = <T extends unknown[]>(
callback: Procedure<T>,
delay: number,
): DebouncedProcedure<T> => {
const timerRef = useRef<NodeJS.Timeout | null>(null);
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useMemo(() => {
const debounced = ((...args: T) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}) as DebouncedProcedure<T>;
debounced.cancel = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
return debounced;
}, [delay]);
};
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { DocumentSearchRequest, DocumentSearchResponse } from "@/types/search";
const fetchDocumentSearch = async (payload: DocumentSearchRequest): Promise<DocumentSearchResponse> => {
const response = await fetch("/api/search/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = (await response.json().catch(() => null))?.error ?? "搜索失败,请稍后再试";
throw new Error(message);
}
return response.json() as Promise<DocumentSearchResponse>;
};
export function useDocumentSearch(payload: DocumentSearchRequest | null, enabled: boolean) {
return useQuery({
queryKey: ["document-search", payload],
queryFn: () => {
if (!payload) {
throw new Error("缺少搜索参数");
}
return fetchDocumentSearch(payload);
},
enabled: enabled && Boolean(payload?.workspaceId),
staleTime: 30_000,
gcTime: 60_000,
});
}

Some files were not shown because too many files have changed in this diff Show More