chore: merge existing main history
This commit is contained in:
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
^C
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+8675
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,11 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [] });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
)();
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
)();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user