diff --git a/.env.example b/.env.example index 6a0233e..99918c9 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ DATABASE_URL="file:./dev.db" SESSION_SECRET="replace-with-a-long-random-secret" +INIT_DEFAULT_PASSWORD="replace-with-initial-password" +# Optional: if set, /api/settings/init requires header x-init-token +INIT_SETUP_TOKEN="replace-with-one-time-init-token" diff --git a/README.md b/README.md index ee03569..d87e251 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,15 @@ ```env DATABASE_URL="file:./dev.db" SESSION_SECRET="replace-with-a-long-random-secret" +INIT_DEFAULT_PASSWORD="replace-with-initial-password" +# Optional: if set, /api/settings/init requires header x-init-token +INIT_SETUP_TOKEN="replace-with-one-time-init-token" ``` - `SESSION_SECRET` 必填,用于服务端签名登录会话。 - 生产环境请使用长度至少 32 的随机字符串。 +- `INIT_DEFAULT_PASSWORD` 用于首次初始化密码(`/api/settings/init`)。 +- 建议在生产环境设置 `INIT_SETUP_TOKEN`,避免未授权初始化。 ## 开发 diff --git a/package.json b/package.json index f1e49df..4211e73 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev -p 3001", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "check": "npm run lint -- --max-warnings=0 && npx tsc --noEmit" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -69,4 +70,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/prisma/dev.db b/prisma/dev.db index 1f27d18..9c2ae12 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/scripts/init-settings.js b/scripts/init-settings.js index be81391..29d718e 100644 --- a/scripts/init-settings.js +++ b/scripts/init-settings.js @@ -13,10 +13,12 @@ async function hashPassword(password) { } async function main() { + const password = process.argv[2] || process.env.INIT_DEFAULT_PASSWORD || "admin"; + const count = await prisma.globalSettings.count(); if (count === 0) { console.log("Initializing default settings..."); - const hashedPassword = await hashPassword("admin"); + const hashedPassword = await hashPassword(password); await prisma.globalSettings.create({ data: { id: "default", diff --git a/scripts/reset-password.js b/scripts/reset-password.js index af6f496..cc40d22 100644 --- a/scripts/reset-password.js +++ b/scripts/reset-password.js @@ -13,8 +13,9 @@ async function hashPassword(password) { } async function main() { - console.log("Resetting password to 'admin'..."); - const hashedPassword = await hashPassword("admin"); + const password = process.argv[2] || process.env.INIT_DEFAULT_PASSWORD || "admin"; + console.log("Resetting password..."); + const hashedPassword = await hashPassword(password); const settings = await prisma.globalSettings.findFirst(); if (settings) { diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts index 720e646..0db85f5 100644 --- a/src/app/api/ai/chat/route.ts +++ b/src/app/api/ai/chat/route.ts @@ -1,20 +1,52 @@ import { NextRequest, NextResponse } from "next/server"; +import { requireApiAuth } from "@/lib/api-auth"; export const runtime = "edge"; // Optional: Use edge runtime for lower latency export async function POST(req: NextRequest) { + const authError = await requireApiAuth(); + if (authError) return authError; + try { const { messages, config } = await req.json(); const { apiKey, baseURL, model } = config || {}; - if (!apiKey) { - return NextResponse.json({ error: "Missing API Key" }, { status: 401 }); + if (!apiKey || typeof apiKey !== "string") { + return NextResponse.json({ error: "Missing API Key" }, { status: 400 }); + } + if (!baseURL || typeof baseURL !== "string") { + return NextResponse.json({ error: "Missing API base URL" }, { status: 400 }); + } + if (!Array.isArray(messages)) { + return NextResponse.json({ error: "Invalid messages payload" }, { status: 400 }); + } + if (messages.length === 0 || messages.length > 100) { + return NextResponse.json({ error: "Messages count out of range" }, { status: 400 }); + } + const isValidMessage = messages.every( + (msg) => + msg && + typeof msg === "object" && + typeof msg.role === "string" && + typeof msg.content === "string" && + msg.content.length <= 20000 + ); + if (!isValidMessage) { + return NextResponse.json({ error: "Invalid message format" }, { status: 400 }); } - // Clean up baseURL: ensure no trailing slash, add /chat/completions if missing? - // Actually, usually users provide standard base URL "https://api.openai.com/v1" - // We should append /chat/completions. - const url = `${baseURL.replace(/\/$/, "")}/chat/completions`; + let normalizedBaseUrl: string; + try { + const parsed = new URL(baseURL); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return NextResponse.json({ error: "Unsupported API base URL protocol" }, { status: 400 }); + } + normalizedBaseUrl = parsed.origin + parsed.pathname.replace(/\/$/, ""); + } catch { + return NextResponse.json({ error: "Invalid API base URL" }, { status: 400 }); + } + + const url = `${normalizedBaseUrl}/chat/completions`; const res = await fetch(url, { method: "POST", @@ -23,7 +55,7 @@ export async function POST(req: NextRequest) { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model: model || "gpt-3.5-turbo", + model: typeof model === "string" && model.trim() ? model.trim() : "gpt-3.5-turbo", messages, stream: true, // Force streaming }), diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index afdd6d9..a1273ba 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -5,7 +5,10 @@ import { createSessionToken, getSessionCookieName, getSessionTtlSeconds } from " export async function POST(req: Request) { try { - const { password, rememberMe, durationDays } = await req.json(); + const { password, rememberMe, durationDays } = await req.json().catch(() => ({})); + if (typeof password !== "string" || password.length === 0) { + return NextResponse.json({ error: "Invalid password" }, { status: 400 }); + } const settings = await prisma.globalSettings.findUnique({ where: { id: "default" }, @@ -18,7 +21,9 @@ export async function POST(req: Request) { const isValid = await verifyPassword(password, settings.password); if (isValid) { - const days = rememberMe ? Number(durationDays) || 1 : 1; + const rawDays = rememberMe ? Number(durationDays) : 1; + const boundedDays = Number.isFinite(rawDays) ? Math.floor(rawDays) : 1; + const days = rememberMe ? Math.max(1, Math.min(30, boundedDays)) : 1; const token = await createSessionToken(days); const response = NextResponse.json({ success: true }); response.cookies.set(getSessionCookieName(), token, { diff --git a/src/app/api/pages/[id]/route.ts b/src/app/api/pages/[id]/route.ts index 846f990..32b895f 100644 --- a/src/app/api/pages/[id]/route.ts +++ b/src/app/api/pages/[id]/route.ts @@ -1,18 +1,87 @@ -import { NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import type { Prisma } from '@prisma/client'; +import { requireApiAuth } from '@/lib/api-auth'; + +type PageRef = { id: string; parentId: string | null }; + +const MAX_TITLE_LENGTH = 200; +const MAX_TAGS = 20; +const MAX_TAG_LENGTH = 50; + +function buildChildrenMap(pages: PageRef[]): Map { + const childrenMap = new Map(); + for (const page of pages) { + const siblings = childrenMap.get(page.parentId) || []; + siblings.push(page.id); + childrenMap.set(page.parentId, siblings); + } + return childrenMap; +} + +function isCycleMove(pages: PageRef[], nodeId: string, targetParentId: string): boolean { + const parentMap = new Map(pages.map((p) => [p.id, p.parentId])); + let cursor: string | null = targetParentId; + + while (cursor) { + if (cursor === nodeId) return true; + cursor = parentMap.get(cursor) ?? null; + } + + return false; +} + +function collectDeleteOrder(rootId: string, pages: PageRef[]): string[] { + const childrenMap = buildChildrenMap(pages); + const order: string[] = []; + + const walk = (id: string) => { + const children = childrenMap.get(id) || []; + for (const childId of children) walk(childId); + order.push(id); + }; + + walk(rootId); + return order; +} + +function safeParseTags(tags: string | null): string[] { + if (!tags) return []; + try { + const parsed = JSON.parse(tags); + return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : []; + } catch { + return []; + } +} + +function normalizeTags(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const unique = new Set(); + for (const raw of input) { + if (typeof raw !== 'string') continue; + const normalized = raw.trim(); + if (!normalized || normalized.length > MAX_TAG_LENGTH) continue; + unique.add(normalized); + if (unique.size >= MAX_TAGS) break; + } + return Array.from(unique); +} export async function GET( request: Request, { params }: { params: Promise<{ id: string }> } ) { + const authError = await requireApiAuth(); + if (authError) return authError; + const id = (await params).id; try { const page = await prisma.page.findUnique({ where: { id }, }); if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 }); - return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); + return NextResponse.json({ ...page, tags: safeParseTags(page.tags) }); } catch { return NextResponse.json({ error: 'Error fetching page' }, { status: 500 }); } @@ -22,23 +91,96 @@ export async function PUT( request: Request, { params }: { params: Promise<{ id: string }> } ) { + const authError = await requireApiAuth(); + if (authError) return authError; + const id = (await params).id; try { - const body = await request.json(); - // Separate update logic for flexibility (e.g. only updating title) + const body = await request.json().catch(() => null); + if (!body || typeof body !== 'object') { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + const current = await prisma.page.findUnique({ where: { id }, select: { id: true } }); + if (!current) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + const updateData: Prisma.PageUncheckedUpdateInput = {}; - if (body.title !== undefined) updateData.title = body.title; - if (body.content !== undefined) updateData.content = body.content; - if (body.parentId !== undefined) updateData.parentId = body.parentId; - if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags); - if (body.icon !== undefined) updateData.icon = body.icon; - if (body.isLocked !== undefined) updateData.isLocked = body.isLocked; + + if (body.title !== undefined) { + if (typeof body.title !== 'string') { + return NextResponse.json({ error: 'Invalid title' }, { status: 400 }); + } + const trimmed = body.title.trim(); + if (!trimmed || trimmed.length > MAX_TITLE_LENGTH) { + return NextResponse.json({ error: 'Invalid title' }, { status: 400 }); + } + updateData.title = trimmed; + } + + if (body.content !== undefined) { + if (typeof body.content !== 'string') { + return NextResponse.json({ error: 'Invalid content' }, { status: 400 }); + } + updateData.content = body.content; + } + + if (body.icon !== undefined) { + updateData.icon = typeof body.icon === 'string' ? body.icon : null; + } + + if (body.isLocked !== undefined) { + if (typeof body.isLocked !== 'boolean') { + return NextResponse.json({ error: 'Invalid isLocked' }, { status: 400 }); + } + updateData.isLocked = body.isLocked; + } + + if (body.tags !== undefined) { + updateData.tags = JSON.stringify(normalizeTags(body.tags)); + } + + if (body.parentId !== undefined) { + if (body.parentId === null) { + updateData.parentId = null; + } else if (typeof body.parentId === 'string') { + const targetParentId = body.parentId; + if (targetParentId === id) { + return NextResponse.json({ error: 'Invalid parentId' }, { status: 400 }); + } + + const targetParent = await prisma.page.findUnique({ + where: { id: targetParentId }, + select: { id: true, type: true }, + }); + if (!targetParent || targetParent.type !== 'folder') { + return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }); + } + + const refs = await prisma.page.findMany({ select: { id: true, parentId: true } }); + if (isCycleMove(refs, id, targetParentId)) { + return NextResponse.json({ error: 'Cannot move page into its own subtree' }, { status: 400 }); + } + + updateData.parentId = targetParentId; + } else { + return NextResponse.json({ error: 'Invalid parentId' }, { status: 400 }); + } + } + + if (body.order !== undefined) { + if (!Number.isInteger(body.order)) { + return NextResponse.json({ error: 'Invalid order' }, { status: 400 }); + } + updateData.order = body.order; + } const page = await prisma.page.update({ where: { id }, data: updateData, }); - return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); + return NextResponse.json({ ...page, tags: safeParseTags(page.tags) }); } catch { return NextResponse.json({ error: 'Error updating page' }, { status: 500 }); } @@ -48,11 +190,24 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> } ) { + const authError = await requireApiAuth(); + if (authError) return authError; + const id = (await params).id; try { - await prisma.page.delete({ - where: { id }, + const refs = await prisma.page.findMany({ select: { id: true, parentId: true } }); + const exists = refs.some((p) => p.id === id); + if (!exists) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + + const deleteOrder = collectDeleteOrder(id, refs); + await prisma.$transaction(async (tx) => { + for (const pageId of deleteOrder) { + await tx.page.delete({ where: { id: pageId } }); + } }); + return NextResponse.json({ success: true }); } catch { return NextResponse.json({ error: 'Error deleting page' }, { status: 500 }); diff --git a/src/app/api/pages/reorder/route.ts b/src/app/api/pages/reorder/route.ts index f7a59c7..7e099e4 100644 --- a/src/app/api/pages/reorder/route.ts +++ b/src/app/api/pages/reorder/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireApiAuth } from '@/lib/api-auth'; export async function PUT(request: Request) { + const authError = await requireApiAuth(); + if (authError) return authError; + try { const body = await request.json(); const { updates } = body; @@ -9,6 +13,24 @@ export async function PUT(request: Request) { if (!Array.isArray(updates)) { return NextResponse.json({ error: 'Invalid updates' }, { status: 400 }); } + if (updates.length === 0) { + return NextResponse.json({ success: true }); + } + const isValid = updates.every( + (update) => + update && + typeof update === 'object' && + typeof update.id === 'string' && + update.id.length > 0 && + Number.isInteger(update.order) + ); + if (!isValid) { + return NextResponse.json({ error: 'Invalid updates payload' }, { status: 400 }); + } + const ids = updates.map((update: { id: string }) => update.id); + if (new Set(ids).size !== ids.length) { + return NextResponse.json({ error: 'Duplicate page ids in updates' }, { status: 400 }); + } // Transaction for batch update await prisma.$transaction( diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts index dc09c1d..8e58515 100644 --- a/src/app/api/pages/route.ts +++ b/src/app/api/pages/route.ts @@ -1,14 +1,45 @@ -import { NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireApiAuth } from '@/lib/api-auth'; + +const MAX_TITLE_LENGTH = 200; +const MAX_TAGS = 20; +const MAX_TAG_LENGTH = 50; + +function safeParseTags(tags: string | null): string[] { + if (!tags) return []; + try { + const parsed = JSON.parse(tags); + return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : []; + } catch { + return []; + } +} + +function normalizeTags(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const unique = new Set(); + for (const raw of input) { + if (typeof raw !== 'string') continue; + const normalized = raw.trim(); + if (!normalized || normalized.length > MAX_TAG_LENGTH) continue; + unique.add(normalized); + if (unique.size >= MAX_TAGS) break; + } + return Array.from(unique); +} export async function GET() { + const authError = await requireApiAuth(); + if (authError) return authError; + try { const pages = await prisma.page.findMany({ orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], }); - const parsedPages = pages.map(p => ({ + const parsedPages = pages.map((p) => ({ ...p, - tags: JSON.parse(p.tags || "[]") + tags: safeParseTags(p.tags), })); return NextResponse.json(parsedPages); } catch { @@ -17,30 +48,59 @@ export async function GET() { } export async function POST(request: Request) { + const authError = await requireApiAuth(); + if (authError) return authError; + try { - const body = await request.json(); - const { title, content, parentId, type } = body; + const body = await request.json().catch(() => null); + if (!body || typeof body !== 'object') { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + const rawTitle = typeof body.title === 'string' ? body.title.trim() : ''; + const title = rawTitle || '无标题'; + if (title.length > MAX_TITLE_LENGTH) { + return NextResponse.json({ error: 'Title too long' }, { status: 400 }); + } + + const type = body.type === 'folder' ? 'folder' : 'file'; + const content = typeof body.content === 'string' ? body.content : ''; + const parentId = typeof body.parentId === 'string' ? body.parentId : null; + const tags = normalizeTags(body.tags); + const icon = typeof body.icon === 'string' ? body.icon : null; + const isLocked = typeof body.isLocked === 'boolean' ? body.isLocked : false; + const requestedOrder = Number.isInteger(body.order) ? body.order : undefined; + + if (parentId) { + const parent = await prisma.page.findUnique({ + where: { id: parentId }, + select: { id: true, type: true }, + }); + if (!parent || parent.type !== 'folder') { + return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }); + } + } const page = await prisma.page.create({ data: { - title: title || '无标题', - content: content || '', - tags: JSON.stringify(body.tags || []), - parentId: parentId || null, - type: type || 'file', + title, + content, + tags: JSON.stringify(tags), + parentId, + type, order: await (async () => { - if (body.order !== undefined) return body.order; + if (requestedOrder !== undefined) return requestedOrder; const lastPage = await prisma.page.findFirst({ - where: { parentId: parentId || null }, + where: { parentId }, orderBy: { order: 'desc' }, }); return (lastPage?.order ?? -1) + 1; })(), - icon: body.icon || null, - isLocked: body.isLocked || false, + icon, + isLocked, }, }); - return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); + return NextResponse.json({ ...page, tags: safeParseTags(page.tags) }); } catch { return NextResponse.json({ error: 'Error creating page' }, { status: 500 }); } diff --git a/src/app/api/settings/init/route.ts b/src/app/api/settings/init/route.ts index 04afc6a..bb5db50 100644 --- a/src/app/api/settings/init/route.ts +++ b/src/app/api/settings/init/route.ts @@ -1,16 +1,43 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { hashPassword } from "@/lib/auth"; -export async function POST() { +export async function POST(req: Request) { try { const count = await prisma.globalSettings.count(); if (count > 0) { return NextResponse.json({ message: "Settings already initialized" }, { status: 200 }); } - // Default password: "admin" - const hashedPassword = await hashPassword("admin"); + const body = await req.json().catch(() => ({})); + const passwordFromBody = typeof body?.password === "string" ? body.password : ""; + const passwordFromEnv = process.env.INIT_DEFAULT_PASSWORD || ""; + const initPassword = passwordFromBody || passwordFromEnv; + + if (!initPassword || initPassword.length < 6) { + return NextResponse.json( + { error: "Missing init password. Provide body.password or INIT_DEFAULT_PASSWORD (min 6 chars)." }, + { status: 400 } + ); + } + + const initToken = process.env.INIT_SETUP_TOKEN; + const isProduction = process.env.NODE_ENV === "production"; + if (isProduction && !initToken) { + return NextResponse.json( + { error: "Server misconfigured: INIT_SETUP_TOKEN is required in production." }, + { status: 500 } + ); + } + + if (initToken) { + const providedToken = req.headers.get("x-init-token"); + if (providedToken !== initToken) { + return NextResponse.json({ error: "Invalid init token" }, { status: 401 }); + } + } + + const hashedPassword = await hashPassword(initPassword); await prisma.globalSettings.create({ data: { id: "default", diff --git a/src/app/api/settings/password/route.ts b/src/app/api/settings/password/route.ts index 4f0cea6..4809f32 100644 --- a/src/app/api/settings/password/route.ts +++ b/src/app/api/settings/password/route.ts @@ -1,10 +1,23 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { hashPassword, verifyPassword } from "@/lib/auth"; +import { requireApiAuth } from "@/lib/api-auth"; export async function PUT(req: Request) { + const authError = await requireApiAuth(); + if (authError) return authError; + try { - const { currentPassword, newPassword } = await req.json(); + const { currentPassword, newPassword } = await req.json().catch(() => ({})); + if (typeof currentPassword !== "string" || currentPassword.length === 0) { + return NextResponse.json({ error: "Invalid current password" }, { status: 400 }); + } + if (typeof newPassword !== "string" || newPassword.length < 6) { + return NextResponse.json({ error: "New password must be at least 6 characters" }, { status: 400 }); + } + if (newPassword === currentPassword) { + return NextResponse.json({ error: "New password must be different" }, { status: 400 }); + } const settings = await prisma.globalSettings.findUnique({ where: { id: "default" }, diff --git a/src/app/api/settings/restore/route.ts b/src/app/api/settings/restore/route.ts index 8fc71ab..36c8880 100644 --- a/src/app/api/settings/restore/route.ts +++ b/src/app/api/settings/restore/route.ts @@ -1,194 +1,200 @@ - import { NextRequest, NextResponse } from "next/server"; -import { PrismaClient } from "@prisma/client"; import JSZip from "jszip"; import { marked } from "marked"; +import { prisma } from "@/lib/prisma"; +import { requireApiAuth } from "@/lib/api-auth"; -// Use a global prisma instance to avoid "too many connections" in dev -const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; -const prisma = globalForPrisma.prisma || new PrismaClient(); -if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; +type ZipEntry = { + path: string; + isDir: boolean; + content?: string; +}; -// Disable body parser strictly (Next.js App Router handles FormData naturally) -// export const config = { -// api: { -// bodyParser: false, -// }, -// }; -// No need for config in App Router route handlers. +type ParsedMarkdown = { + title: string; + order: number; + tags: string[]; + htmlContent: string; +}; + +const MAX_RESTORE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + +function normalizeZipPath(input: string): string { + const noBackslashes = input.replace(/\\/g, "/"); + const noTrailingSlash = noBackslashes.endsWith("/") ? noBackslashes.slice(0, -1) : noBackslashes; + return noTrailingSlash.trim(); +} + +function getDepth(path: string): number { + return path.split("/").length; +} + +function getParentPath(path: string): string | null { + const parts = path.split("/"); + if (parts.length <= 1) return null; + return parts.slice(0, -1).join("/"); +} + +function getNameFromPath(path: string): string { + return path.split("/").pop() || "Untitled"; +} + +async function parseMarkdownWithFrontmatter(filePath: string, content: string): Promise { + const defaultTitle = getNameFromPath(filePath).replace(/\.md$/i, "") || "Untitled"; + let title = defaultTitle; + let tags: string[] = []; + let order = 0; + let markdownBody = content; + + const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/); + if (fmMatch) { + const fmString = fmMatch[1]; + markdownBody = markdownBody.slice(fmMatch[0].length); + + const titleMatch = fmString.match(/title:\s*"(.*)"/); + if (titleMatch && titleMatch[1]) title = titleMatch[1]; + + const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/); + if (tagsMatch && tagsMatch[1]) { + tags = tagsMatch[1] + .split(",") + .map((item) => item.trim().replace(/^"|"$/g, "")) + .filter(Boolean); + } + + const orderMatch = fmString.match(/order:\s*(\d+)/); + if (orderMatch && orderMatch[1]) { + order = parseInt(orderMatch[1], 10); + } + } + + const htmlContent = await marked(markdownBody, { gfm: true, breaks: true }); + return { title, order, tags, htmlContent }; +} + +function collectFolderPaths(entries: ZipEntry[]): string[] { + const folderSet = new Set(); + + for (const entry of entries) { + const normalized = normalizeZipPath(entry.path); + if (!normalized) continue; + + if (entry.isDir) { + folderSet.add(normalized); + } + + let current = getParentPath(normalized); + while (current) { + folderSet.add(current); + current = getParentPath(current); + } + } + + return Array.from(folderSet).sort((a, b) => getDepth(a) - getDepth(b)); +} export async function POST(req: NextRequest) { + const authError = await requireApiAuth(); + if (authError) return authError; + try { const formData = await req.formData(); - const file = formData.get("file") as File; + const file = formData.get("file"); - if (!file) { + if (!(file instanceof File)) { return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); } + if (file.size <= 0) { + return NextResponse.json({ error: "Empty file" }, { status: 400 }); + } + if (file.size > MAX_RESTORE_FILE_SIZE) { + return NextResponse.json({ error: "File too large" }, { status: 413 }); + } const buffer = await file.arrayBuffer(); const zip = await JSZip.loadAsync(buffer); + const entries: ZipEntry[] = []; + const loadTasks: Promise[] = []; - // Map to store directory paths to their new DB IDs - // Data format: "folder/subfolder" -> UUID - const pathIdMap = new Map(); + zip.forEach((rawPath, zipEntry) => { + if (rawPath.startsWith("__MACOSX") || rawPath.includes(".DS_Store")) return; - // Prepare data for proper insertion order (Folders first, then files?) - // Actually, we need to process by path depth to ensure parents exist. - const entries: Array<{ path: string; isDir: boolean; content?: string }> = []; + loadTasks.push((async () => { + const path = normalizeZipPath(rawPath); + if (!path) return; - // 1. Read all entries - const filePromises: Promise[] = []; - - zip.forEach((relativePath, zipEntry) => { - if (relativePath.startsWith("__MACOSX") || relativePath.includes(".DS_Store")) { - return; // Skip system files - } - - const promise = (async () => { if (zipEntry.dir) { - // Remove trailing slash for consistency - const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; - if (cleanPath) { - entries.push({ path: cleanPath, isDir: true }); - } - } else { - if (relativePath.endsWith(".md")) { - const content = await zipEntry.async("string"); - entries.push({ path: relativePath, isDir: false, content }); - } + entries.push({ path, isDir: true }); + return; } - })(); - filePromises.push(promise); + if (!path.endsWith(".md")) return; + + const content = await zipEntry.async("string"); + entries.push({ path, isDir: false, content }); + })()); }); - await Promise.all(filePromises); - - // 2. Clear existing Pages (Transaction usually) - // Since sqlite doesn't support nested transactions well in Prisma sometimes, - // we'll just do it sequentially but quickly. - // Ideally: await prisma.$transaction([prisma.page.deleteMany(), ...]) - // But logic is complex (recursive id generation), so we delete first. - // WARNING: This is destructive. - await prisma.page.deleteMany(); - - // 3. Sort entries by path depth (number of slashes) - entries.sort((a, b) => { - const depthA = a.path.split('/').length; - const depthB = b.path.split('/').length; - return depthA - depthB; - }); - - // Helper to get or create parent folder - const ensureParent = async (entryPath: string): Promise => { - const parts = entryPath.split('/'); - if (parts.length <= 1) return null; // Root level - - const parentPath = parts.slice(0, -1).join('/'); - - // If parent already processed - if (pathIdMap.has(parentPath)) { - return pathIdMap.get(parentPath)!; - } - - // If parent folder was not explicitly in Zip (implicit folder), create it - // Recursively ensure its parent exists - const grandParentId = await ensureParent(parentPath); - const folderName = parts[parts.length - 2]; - - const newFolder = await prisma.page.create({ - data: { - title: folderName, - type: 'folder', - parentId: grandParentId - } - }); - - pathIdMap.set(parentPath, newFolder.id); - return newFolder.id; - }; - - // 4. Process entries - for (const entry of entries) { - // Determine parent - // If it's a file "A/B.md", parent path is "A". - // If it's a folder "A/B", parent path is "A". - // Since we sorted by depth, "A" should be processed before "A/B". - - // However, implicit folders might be skipped in sorting if they aren't in `entries`. - // So `ensureParent` handles implicit creation. - - const parentId = await ensureParent(entry.path); - - if (entry.isDir) { - // Check if already created by ensureParent - if (!pathIdMap.has(entry.path)) { - const name = entry.path.split('/').pop() || "Untitled Folder"; - const folder = await prisma.page.create({ - data: { - title: name, - type: 'folder', - parentId: parentId - } - }); - pathIdMap.set(entry.path, folder.id); - } - } else { - // It is a File (.md) - const filename = entry.path.split('/').pop()?.replace('.md', '') || "Untitled"; - - // Parse Frontmatter - let title = filename; - let tags: string[] = []; - let order = 0; - let markdownBody = entry.content || ""; - - // Regex for frontmatter - const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/); - if (fmMatch) { - const fmString = fmMatch[1]; - markdownBody = markdownBody.slice(fmMatch[0].length); - - // Simple parsing - // title: "Foo" - // tags: ["a", "b"] - // order: 1 - - const titleMatch = fmString.match(/title:\s*"(.*)"/); - if (titleMatch) title = titleMatch[1]; - - const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/); - if (tagsMatch) { - // "a", "b" -> split - tags = tagsMatch[1].split(',').map(s => s.trim().replace(/^"|"$/g, '')).filter(Boolean); - } - - const orderMatch = fmString.match(/order:\s*(\d+)/); - if (orderMatch) order = parseInt(orderMatch[1]); - } - - // Convert Markdown to HTML for storage (Editor uses HTML) - // Ensure GFM is enabled (default true in new versions, but explicit is good) - // breaks: true converts \n to
(GitHub style) - const htmlContent = await marked(markdownBody, { gfm: true, breaks: true }); - - await prisma.page.create({ - data: { - title: title, - type: 'file', - content: htmlContent, - tags: JSON.stringify(tags), - order: order, - parentId: parentId - } - }); - } + await Promise.all(loadTasks); + if (entries.length === 0) { + return NextResponse.json({ error: "No valid markdown entries found in zip" }, { status: 400 }); } - return NextResponse.json({ success: true, count: entries.length }); + const folderPaths = collectFolderPaths(entries); + const fileEntries = entries + .filter((entry): entry is ZipEntry & { content: string } => !entry.isDir && typeof entry.content === "string") + .sort((a, b) => getDepth(a.path) - getDepth(b.path)); - } catch (e) { - console.error("Restore failed:", e); - return NextResponse.json({ error: "Restore failed: " + String(e) }, { status: 500 }); + // Parse markdown before DB transaction to keep lock time low. + const parsedFiles = await Promise.all( + fileEntries.map(async (entry) => { + const parsed = await parseMarkdownWithFrontmatter(entry.path, entry.content); + return { entry, parsed }; + }) + ); + + await prisma.$transaction(async (tx) => { + await tx.page.deleteMany(); + + const folderIdMap = new Map(); + for (const folderPath of folderPaths) { + const parentPath = getParentPath(folderPath); + const parentId = parentPath ? folderIdMap.get(parentPath) || null : null; + + const folder = await tx.page.create({ + data: { + title: getNameFromPath(folderPath), + type: "folder", + parentId, + }, + }); + folderIdMap.set(folderPath, folder.id); + } + + for (const { entry, parsed } of parsedFiles) { + const parentPath = getParentPath(entry.path); + const parentId = parentPath ? folderIdMap.get(parentPath) || null : null; + + await tx.page.create({ + data: { + title: parsed.title, + type: "file", + content: parsed.htmlContent, + tags: JSON.stringify(parsed.tags), + order: parsed.order, + parentId, + }, + }); + } + }); + + return NextResponse.json({ + success: true, + count: entries.length, + files: parsedFiles.length, + folders: folderPaths.length, + }); + } catch (error) { + console.error("Restore failed:", error); + return NextResponse.json({ error: "Restore failed" }, { status: 500 }); } } diff --git a/src/app/globals.css b/src/app/globals.css index 7646562..18a69b1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,62 +5,58 @@ @layer base { :root { - --background: 240 10% 93%; - /* Main content: 93% gray */ - --foreground: 240 5% 20%; - --card: 0 0% 100%; - --card-foreground: 240 5% 15%; - --popover: 0 0% 100%; - --popover-foreground: 240 5% 15%; - --primary: 240 5.9% 10%; + --background: 38 20% 95%; + /* Softer warm gray background */ + --foreground: 225 11% 22%; + --card: 40 17% 98%; + --card-foreground: 225 11% 22%; + --popover: 40 17% 98%; + --popover-foreground: 225 11% 22%; + --primary: 223 21% 20%; --primary-foreground: 0 0% 98%; - --secondary: 240 10% 90%; - /* Sidebar: 90% gray */ - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 96%; - --muted-foreground: 240 3.8% 46%; - --accent: 240 4.8% 96%; - --accent-foreground: 240 5.9% 10%; + --secondary: 36 18% 92%; + --secondary-foreground: 223 18% 24%; + --muted: 36 16% 93%; + --muted-foreground: 223 9% 42%; + --accent: 34 20% 91%; + --accent-foreground: 223 18% 24%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 5.9% 10%; + --border: 35 15% 85%; + --input: 35 15% 85%; + --ring: 223 21% 30%; --radius: 0.75rem; } .dark { - /* Notion-like Dark Mode (Inverted Hierarchy) - Deepened */ - --background: 0 0% 9%; - /* Main Content: Deep Dark (#171717) */ - --foreground: 0 0% 92%; - --card: 0 0% 9%; - /* Match background */ - --card-foreground: 0 0% 92%; + /* Softer dark mode: less pure black, gentler contrast */ + --background: 220 10% 13%; + --foreground: 210 16% 90%; - --popover: 0 0% 9%; - --popover-foreground: 0 0% 92%; + --card: 220 10% 15%; + --card-foreground: 210 16% 90%; - --primary: 0 0% 92%; - --primary-foreground: 0 0% 10%; + --popover: 220 10% 16%; + --popover-foreground: 210 16% 90%; - --secondary: 0 0% 13%; - /* Sidebar: Lighter than main (#212121), but deeper than before */ - --secondary-foreground: 0 0% 92%; + --primary: 210 16% 90%; + --primary-foreground: 220 12% 14%; - --muted: 0 0% 13%; - --muted-foreground: 0 0% 65%; + --secondary: 220 10% 18%; + --secondary-foreground: 210 16% 90%; - --accent: 0 0% 13%; - --accent-foreground: 0 0% 92%; + --muted: 220 10% 20%; + --muted-foreground: 210 10% 72%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 92%; + --accent: 220 10% 22%; + --accent-foreground: 210 16% 92%; - --border: 0 0% 18%; - /* Subtle borders */ - --input: 0 0% 18%; - --ring: 0 0% 80%; + --destructive: 0 62.8% 35%; + --destructive-foreground: 0 0% 96%; + + --border: 220 8% 28%; + --input: 220 8% 28%; + --ring: 210 16% 78%; } * { @@ -209,12 +205,12 @@ ul[data-type="taskList"], /* Override Highlight.js background for a softer look */ .ProseMirror pre { - background: #252529 !important; - /* Softer dark gray (hsl(240 5% 15%)), approx matching foreground */ + background: #2b313a !important; + /* Slightly lifted code block background for comfortable reading */ border-radius: 0.5rem; } .hljs { background: transparent !important; /* Let pre handle the background */ -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f0a5576..a7bb326 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; +import { ConfirmProvider } from "@/components/confirm-provider"; const inter = Inter({ subsets: ["latin"] }); @@ -24,7 +25,7 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a5d9db4..8406200 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -30,10 +30,10 @@ export default function LoginPage() { router.push("/"); } else { const data = await res.json(); - setError(data.error || "Login failed"); + setError(data.error || "登录失败"); } } catch { - setError("Something went wrong. Please try again."); + setError("发生错误,请重试"); } }; @@ -44,8 +44,8 @@ export default function LoginPage() {
-

Welcome back

-

Enter your access password to continue.

+

欢迎回来

+

请输入访问密码以继续。

@@ -54,7 +54,7 @@ export default function LoginPage() { setPassword(e.target.value)} className="w-full pl-10 pr-4 py-3 bg-muted/50 border rounded-xl focus:ring-2 focus:ring-primary outline-none transition-all" @@ -72,7 +72,7 @@ export default function LoginPage() { onChange={(e) => setRememberMe(e.target.checked)} className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50" /> - Remember me + 记住我 {rememberMe && ( @@ -81,9 +81,9 @@ export default function LoginPage() { onChange={(e) => setDuration(e.target.value)} className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs" > - - - + + + )} @@ -92,12 +92,12 @@ export default function LoginPage() { type="submit" className="w-full py-3 bg-primary text-primary-foreground font-semibold rounded-xl hover:opacity-90 active:scale-[0.98] transition-all shadow-lg" > - Sign in + 登录
- NoteAI - Your private second brain + NoteAI - 你的私人第二大脑
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 5da3c14..55f01c9 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,35 +1,41 @@ -"use client"; +"use client"; import { useState } from "react"; -import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store"; -import { Lock, Type, Save, Sparkles } from "lucide-react"; +import { Lock, Save, Sparkles, Type, Upload, Download } from "lucide-react"; import { ResizableSidebar } from "@/components/sidebar"; import { PromptManagement } from "@/components/settings/prompt-management"; import { ImportProvider } from "@/components/import-context"; +import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store"; +import { useConfirm } from "@/components/confirm-provider"; export default function SettingsPage() { - // Appearance + const confirm = useConfirm(); const { - fontSize, setFontSize, - fontFamily, setFontFamily, - lineHeight, setLineHeight, - tableLineHeight, setTableLineHeight, - timezone, setTimezone, - aiConfig, setAIConfig, + fontSize, + setFontSize, + fontFamily, + setFontFamily, + lineHeight, + setLineHeight, + tableLineHeight, + setTableLineHeight, + timezone, + setTimezone, + aiConfig, + setAIConfig, } = useSettingsStore(); - // Security const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [msg, setMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null); + const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); const handlePasswordChange = async (e: React.FormEvent) => { e.preventDefault(); setMsg(null); if (newPassword !== confirmPassword) { - setMsg({ type: 'error', text: "两次输入的新密码不一致" }); + setMsg({ type: "error", text: "两次输入的新密码不一致" }); return; } @@ -41,412 +47,268 @@ export default function SettingsPage() { }); if (res.ok) { - setMsg({ type: 'success', text: "密码修改成功" }); + setMsg({ type: "success", text: "密码修改成功" }); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } else { const data = await res.json(); - setMsg({ type: 'error', text: data.error || "修改失败" }); + setMsg({ type: "error", text: data.error || "修改失败" }); } } catch { - setMsg({ type: 'error', text: "系统错误,请重试" }); + setMsg({ type: "error", text: "系统错误,请重试" }); } }; return ( -
+
-
- {/* Settings Navigation Sidebar (Desktop Only) */} - +
+
+
+

设置

+

管理外观、AI、安全与备份。

+
- {/* Settings Content Area */} -
-
- {/* Mobile Header (Hidden on Desktop) */} -
-

设置

-

管理您的编辑器偏好和账户安全

+
+
+ +

外观设置

- {/* Appearance Section */} -
-
- -

外观设置

-
- -
-
- - -
- -
- -
- setFontSize(parseInt(e.target.value))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" - /> - {fontSize} -
-
- -
- -
- setLineHeight(parseFloat(e.target.value))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" - /> - {lineHeight} -
-
- -
- -
- setTableLineHeight(parseFloat(e.target.value))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" - /> - {tableLineHeight} -
-
- - {/* Timezone takes full width or 2 cols */} -
- -
- - {/* Future placeholder for more timezone settings or info */} -
-
-
-
- -
-

预览:

-
+ - {/* AI Configuration Section */} -
-
- -

AI 模型配置

-
+ -
-
- - setAIConfig({ baseURL: e.target.value })} - placeholder="https://api.openai.com/v1" - className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono" - /> -

兼容 OpenAI 接口标准的地址

-
+ -
- - setAIConfig({ apiKey: e.target.value })} - placeholder="sk-..." - className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono" - /> -
- -
- - setAIConfig({ model: e.target.value })} - placeholder="gpt-3.5-turbo" - className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono" - /> -
- -
- -
-
-
- - {/* Prompt Management Section */} -
- +
- {/* Security Section */} -
-
- -

安全设置

-
+ +
-
-
- - setCurrentPassword(e.target.value)} - className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50" - required - /> -
-
- - setNewPassword(e.target.value)} - className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50" - required - /> -
-
- - setConfirmPassword(e.target.value)} - className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50" - required - /> +
+
+ +

AI 配置

+
+ +
+ + + + + +
+
+ +
+ +
+ +
+
+ +

安全设置

+
+ + + setCurrentPassword(e.target.value)} + className="w-full rounded-lg border bg-muted/40 px-3 py-2" + placeholder="当前密码" + required + /> + setNewPassword(e.target.value)} + className="w-full rounded-lg border bg-muted/40 px-3 py-2" + placeholder="新密码" + required + /> + setConfirmPassword(e.target.value)} + className="w-full rounded-lg border bg-muted/40 px-3 py-2" + placeholder="确认新密码" + required + /> + + {msg && ( +
+ {msg.text}
+ )} - {msg && ( -
- {msg.text} -
- )} + + +
- - -
+
+

备份与恢复

+

恢复会覆盖当前全部文档,请谨慎操作。

- {/* Data Management Section */} -
-
- 📦 数据备份与恢复 -
+
+ -
-

- ⚠️ 注意事项 -

-
    -
  • 备份功能将导出所有文档为 Markdown 格式的压缩包 (Zip)。
  • -
  • 恢复功能将清空当前所有文档,并用备份文件覆盖,请谨慎操作。
  • -
-
+
- - {/* Version Info */} -
-

NoteAI v{process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0'}

+ /> +
+
+ +
+ NoteAI v{process.env.NEXT_PUBLIC_APP_VERSION || "0.1.0"}
-
-
+ +
); } diff --git a/src/components/chat/ai-chat-panel.tsx b/src/components/chat/ai-chat-panel.tsx index a89780e..2eebfbb 100644 --- a/src/components/chat/ai-chat-panel.tsx +++ b/src/components/chat/ai-chat-panel.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/lib/settings-store"; import { Editor } from "@tiptap/react"; import { marked } from "marked"; +import { sanitizeHtml } from "@/lib/sanitize-html"; interface Message { id: string; @@ -189,7 +190,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) { {msg.role === "assistant" ? (
) : (

{msg.content}

diff --git a/src/components/confirm-provider.tsx b/src/components/confirm-provider.tsx new file mode 100644 index 0000000..a6355ec --- /dev/null +++ b/src/components/confirm-provider.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import * as Dialog from "@radix-ui/react-dialog"; + +type ConfirmOptions = { + title: string; + description?: string; + confirmText?: string; + cancelText?: string; + tone?: "default" | "danger"; +}; + +type ConfirmRequest = ConfirmOptions & { + resolve: (value: boolean) => void; +}; + +type ConfirmFn = (options: ConfirmOptions) => Promise; + +const ConfirmContext = React.createContext(null); + +export function useConfirm(): ConfirmFn { + const context = React.useContext(ConfirmContext); + if (!context) { + throw new Error("useConfirm must be used within ConfirmProvider"); + } + return context; +} + +export function ConfirmProvider({ children }: { children: React.ReactNode }) { + const [request, setRequest] = React.useState(null); + const [open, setOpen] = React.useState(false); + + const closeWith = React.useCallback((value: boolean) => { + if (request) { + request.resolve(value); + setRequest(null); + } + setOpen(false); + }, [request]); + + const confirm = React.useCallback((options) => { + return new Promise((resolve) => { + setRequest({ ...options, resolve }); + setOpen(true); + }); + }, []); + + return ( + + {children} + + { + if (!nextOpen) closeWith(false); + setOpen(nextOpen); + }} + > + + + + + {request?.title} + + {request?.description && ( + + {request.description} + + )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 0cd50bb..26d835a 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -1,6 +1,6 @@ -"use client"; +"use client"; -import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react"; +import { useEditor, EditorContent, ReactNodeViewRenderer, type Editor as TiptapEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { useEffect, useState, useRef } from "react"; import { AIAssist } from "./ai-assist"; @@ -8,10 +8,9 @@ import { Sparkles } from "lucide-react"; import { Toolbar } from "./editor/toolbar"; import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./editor/slash-command"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; -import { lowlight } from 'lowlight'; +import { lowlight } from "lowlight"; import { CodeBlockComponent } from "./editor/code-block"; import Link from "@tiptap/extension-link"; - import Underline from "@tiptap/extension-underline"; import Subscript from "@tiptap/extension-subscript"; import Superscript from "@tiptap/extension-superscript"; @@ -21,7 +20,7 @@ import TaskItem from "@tiptap/extension-task-item"; import { Callout } from "./editor/extensions/callout"; import { AIMark } from "./editor/extensions/ai-mark"; import { TaskItemComponent } from "./editor/extensions/task-item"; -import { useSettingsStore } from "@/lib/settings-store"; +import { useSettingsStore, type AIPrompt } from "@/lib/settings-store"; import { Table } from "@tiptap/extension-table"; import TableRow from "@tiptap/extension-table-row"; import TableCell from "@tiptap/extension-table-cell"; @@ -30,12 +29,12 @@ import Image from "@tiptap/extension-image"; import Youtube from "@tiptap/extension-youtube"; import TextAlign from "@tiptap/extension-text-align"; import Gapcursor from "@tiptap/extension-gapcursor"; -import { Markdown } from 'tiptap-markdown'; +import { Markdown } from "tiptap-markdown"; interface EditorProps { content: string; onChange: (content: string) => void; - onEditorReady?: (editor: any) => void; + onEditorReady?: (editor: TiptapEditor) => void; onToggleAI?: () => void; onExport?: () => void; editable?: boolean; @@ -45,7 +44,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, const [showAI, setShowAI] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const abortControllerRef = useRef(null); - const { fontFamily, fontSize } = useSettingsStore(); + const { fontFamily, fontSize, lineHeight, tableLineHeight } = useSettingsStore(); const editor = useEditor({ extensions: [ @@ -70,17 +69,15 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, render: renderSuggestionItems, }, }), - CodeBlockLowlight - .extend({ - addNodeView() { - return ReactNodeViewRenderer(CodeBlockComponent) - } - }) - .configure({ lowlight, defaultLanguage: 'plaintext' }), + CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockComponent); + }, + }).configure({ lowlight, defaultLanguage: "plaintext" }), Link.configure({ openOnClick: false, HTMLAttributes: { - class: 'cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors', + class: "cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors", }, }), Underline, @@ -94,8 +91,8 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, nested: true, }).extend({ addNodeView() { - return ReactNodeViewRenderer(TaskItemComponent) - } + return ReactNodeViewRenderer(TaskItemComponent); + }, }), Callout, AIMark, @@ -113,15 +110,15 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, controls: false, }), TextAlign.configure({ - types: ['heading', 'paragraph'], + types: ["heading", "paragraph"], }), Markdown.configure({ - html: true, // Allow HTML input/output - transformPastedText: true, // Auto-transform pasted markdown - transformCopiedText: true, // Auto-transform copied markdown - }) + html: true, + transformPastedText: true, + transformCopiedText: true, + }), ], - content: content, + content, onUpdate: ({ editor }) => { if (editor.getHTML() !== content) { onChange(editor.getHTML()); @@ -130,11 +127,11 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, editorProps: { attributes: { class: "prose prose-zinc dark:prose-invert max-w-none focus:outline-none min-h-[300px]", - style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${useSettingsStore.getState().lineHeight}; --table-line-height: ${useSettingsStore.getState().tableLineHeight};`, + style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${lineHeight}; --table-line-height: ${tableLineHeight};`, spellcheck: "false", }, }, - editable: editable, + editable, immediatelyRender: false, }); @@ -163,7 +160,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, setIsGenerating(false); }; - const handleAISuggest = async (prompt: any) => { + const handleAISuggest = async (prompt: AIPrompt) => { if (!editor) return; const { aiConfig } = useSettingsStore.getState(); @@ -175,8 +172,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, const { from, to } = editor.state.selection; const selectedText = editor.state.doc.textBetween(from, to, " "); - // Custom prompt logic - const systemPrompt = prompt.systemPrompt || "You are a helpful assistant."; + const systemPrompt = prompt.systemPrompt || "你是一个乐于助人的助手。"; let userPrompt = selectedText; if (!userPrompt) { @@ -196,10 +192,10 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, config: aiConfig, messages: [ { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt } - ] + { role: "user", content: userPrompt }, + ], }), - signal: abortControllerRef.current.signal + signal: abortControllerRef.current.signal, }); if (!response.ok || !response.body) { @@ -209,33 +205,33 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, const reader = response.body.getReader(); const decoder = new TextDecoder(); - editor.chain().focus().insertContent("\n\n").toggleMark('aiMark').run(); + editor.chain().focus().insertContent("\n\n").toggleMark("aiMark").run(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); - const lines = chunk.split('\n'); + const lines = chunk.split("\n"); for (const line of lines) { - if (line.startsWith('data: ') && line !== 'data: [DONE]') { + if (line.startsWith("data: ") && line !== "data: [DONE]") { try { const data = JSON.parse(line.slice(6)); - const content = data.choices[0]?.delta?.content; + const delta = data.choices?.[0]?.delta; + const content = typeof delta?.content === "string" ? delta.content : ""; if (content) { editor.commands.insertContent(content); } } catch { - // ignore + // Ignore malformed stream chunk. } } } } - editor.chain().focus().insertContent("\n\n").unsetMark('aiMark').run(); - - } catch (e: any) { - if (e.name === 'AbortError') { - editor.chain().focus().insertContent(" [已停止]").unsetMark('aiMark').run(); + editor.chain().focus().insertContent("\n\n").unsetMark("aiMark").run(); + } catch (e: unknown) { + if (e instanceof Error && e.name === "AbortError") { + editor.chain().focus().insertContent(" [已停止]").unsetMark("aiMark").run(); } else { console.error("AI Error", e); alert("AI 请求失败,请检查配置或网络"); @@ -254,11 +250,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, {showAI && (
- setShowAI(false)} - /> + setShowAI(false)} />
)} @@ -275,16 +267,13 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, setShowAI(!showAI); } }} - className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${isGenerating - ? "bg-red-500 text-white animate-pulse" - : "bg-primary text-primary-foreground" - }`} - title={isGenerating ? "停止生成 (Stop)" : "AI 助手 (AI Assist)"} + className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${ + isGenerating ? "bg-red-500 text-white animate-pulse" : "bg-primary text-primary-foreground" + }`} + title={isGenerating ? "停止生成" : "AI 助手"} > {isGenerating ?
: } - -
); } diff --git a/src/components/editor/code-block.tsx b/src/components/editor/code-block.tsx index 9cc1e4e..0d02a23 100644 --- a/src/components/editor/code-block.tsx +++ b/src/components/editor/code-block.tsx @@ -13,6 +13,7 @@ export function CodeBlockComponent({ editor, getPos, }: NodeViewProps) { + const codeBg = "#2b313a"; const { language: defaultLanguage } = node.attrs; const { textContent } = node; const [copied, setCopied] = React.useState(false); @@ -70,7 +71,7 @@ export function CodeBlockComponent({ }, [textContent]); return ( - + {/* Header */}
@@ -126,10 +127,10 @@ export function CodeBlockComponent({
{/* Code Area with Line Numbers - Use grid for better alignment control */} -
+
{/* Line Numbers Gutter */}
@@ -139,7 +140,7 @@ export function CodeBlockComponent({
{/* Actual Code Content */} -
+                
                     
                 
diff --git a/src/components/editor/command-list.tsx b/src/components/editor/command-list.tsx index 1b60796..4a41dd3 100644 --- a/src/components/editor/command-list.tsx +++ b/src/components/editor/command-list.tsx @@ -1,28 +1,40 @@ -"use client"; +"use client"; import React, { Component } from "react"; import { cn } from "@/lib/utils"; +import type { Editor, Range } from "@tiptap/core"; -export class CommandList extends Component<{ - items: any[]; - command: any; - editor: any; - range: any; -}, { +export type SlashItemGroup = "基础" | "列表" | "插入" | "样式"; + +export interface CommandItem { + title: string; + description?: string; + group: SlashItemGroup; + icon: React.ReactNode; + shortcut?: string; + command: (context: { editor: Editor; range: Range }) => void; +} + +type CommandListProps = { + items: CommandItem[]; + command: (item: CommandItem) => void; +}; + +type CommandListState = { selectedIndex: number; -}> { - constructor(props: any) { +}; + +export class CommandList extends Component { + constructor(props: CommandListProps) { super(props); this.state = { selectedIndex: 0, }; } - componentDidUpdate(prevProps: any) { + componentDidUpdate(prevProps: CommandListProps) { if (this.props.items !== prevProps.items) { - this.setState({ - selectedIndex: 0, - }); + this.setState({ selectedIndex: 0 }); } } @@ -46,17 +58,19 @@ export class CommandList extends Component<{ } upHandler() { - this.setState({ - selectedIndex: - (this.state.selectedIndex + this.props.items.length - 1) % - this.props.items.length, - }); + const total = this.getFlattenedItems().length; + if (total === 0) return; + this.setState((prev) => ({ + selectedIndex: (prev.selectedIndex + total - 1) % total, + })); } downHandler() { - this.setState({ - selectedIndex: (this.state.selectedIndex + 1) % this.props.items.length, - }); + const total = this.getFlattenedItems().length; + if (total === 0) return; + this.setState((prev) => ({ + selectedIndex: (prev.selectedIndex + 1) % total, + })); } enterHandler() { @@ -77,32 +91,24 @@ export class CommandList extends Component<{ return Object.values(grouped).flat(); } - groupItems(items: any[]) { - const groups: Record = { - "基础": [], - "排版": [], - "插入": [], - "高级": [], - "列表": [], - "样式": [] + groupItems(items: CommandItem[]) { + const groups: Record = { + 基础: [], + 列表: [], + 插入: [], + 样式: [], }; - items.forEach(item => { - if (item.group && groups[item.group]) { - groups[item.group].push(item); - } else { - groups["基础"].push(item); - } + items.forEach((item) => { + groups[item.group].push(item); }); - // Remove empty groups - return Object.keys(groups) - .filter(key => groups[key].length > 0) + return (Object.keys(groups) as SlashItemGroup[]) + .filter((key) => groups[key].length > 0) .reduce((obj, key) => { - // @ts-ignore obj[key] = groups[key]; return obj; - }, {} as Record); + }, {} as Record); } render() { @@ -114,13 +120,13 @@ export class CommandList extends Component<{ return (
- {Object.entries(grouped).map(([groupName, groupItems]: [string, any[]]) => ( + {Object.entries(grouped).map(([groupName, groupItems]) => (
{groupName}
- {groupItems.map((item: any, index: number) => { + {groupItems.map((item, index) => { const currentGlobalIndex = globalIndex++; const isSelected = selectedIndex === currentGlobalIndex; return ( @@ -131,19 +137,19 @@ export class CommandList extends Component<{ ? "bg-zinc-800 text-zinc-100" : "text-zinc-400 hover:bg-zinc-900/50 hover:text-zinc-200" )} - key={index} + key={`${item.title}-${index}`} onClick={() => this.selectItem(currentGlobalIndex)} >
-
+
{item.icon}
- - {item.title} - + {item.title}
{item.shortcut && ( @@ -159,7 +165,7 @@ export class CommandList extends Component<{
{items.length === 0 && (
-

No matching commands

+

没有匹配的命令

)}
diff --git a/src/components/editor/extensions/ai-mark.ts b/src/components/editor/extensions/ai-mark.ts index 8adf73f..4b3f28a 100644 --- a/src/components/editor/extensions/ai-mark.ts +++ b/src/components/editor/extensions/ai-mark.ts @@ -1,7 +1,7 @@ import { Mark, mergeAttributes } from '@tiptap/core'; export interface AIMarkOptions { - HTMLAttributes: Record; + HTMLAttributes: Record; } export const AIMark = Mark.create({ diff --git a/src/components/editor/extensions/callout.ts b/src/components/editor/extensions/callout.ts index 75f6abd..7ed539c 100644 --- a/src/components/editor/extensions/callout.ts +++ b/src/components/editor/extensions/callout.ts @@ -3,7 +3,7 @@ import { ReactNodeViewRenderer } from '@tiptap/react' import { CalloutComponent } from './callout-component' export interface CalloutOptions { - HTMLAttributes: Record + HTMLAttributes: Record } declare module '@tiptap/core' { diff --git a/src/components/editor/slash-command.tsx b/src/components/editor/slash-command.tsx index 56ffc63..620e01b 100644 --- a/src/components/editor/slash-command.tsx +++ b/src/components/editor/slash-command.tsx @@ -1,15 +1,36 @@ -import { Extension } from "@tiptap/core"; -import Suggestion from "@tiptap/suggestion"; +import { Extension, type Editor, type Range } from "@tiptap/core"; +import Suggestion, { type SuggestionProps } from "@tiptap/suggestion"; import { ReactRenderer } from "@tiptap/react"; -import tippy, { Instance as TippyInstance } from "tippy.js"; -import { CommandList } from "./command-list"; +import tippy, { type Instance as TippyInstance } from "tippy.js"; +import { CommandList, type CommandItem } from "./command-list"; import { - Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, - List, ListOrdered, Quote, - Code, CheckSquare, Minus, Info, Type, - Bold, Italic, Underline as UnderlineIcon, Strikethrough, Highlighter + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + List, + ListOrdered, + Quote, + Code, + CheckSquare, + Minus, + Info, + Type, + Bold, + Italic, + Underline as UnderlineIcon, + Strikethrough, + Highlighter, } from "lucide-react"; +type CommandContext = { editor: Editor; range: Range }; + +function run(editor: Editor, range: Range, action: (ctx: CommandContext) => void) { + action({ editor, range }); +} + export const SlashCommand = Extension.create({ name: "slashCommand", @@ -17,7 +38,7 @@ export const SlashCommand = Extension.create({ return { suggestion: { char: "/", - command: ({ editor, range, props }: any) => { + command: ({ editor, range, props }: { editor: Editor; range: Range; props: CommandItem }) => { props.command({ editor, range }); }, }, @@ -34,215 +55,246 @@ export const SlashCommand = Extension.create({ }, }); -export const getSuggestionItems = ({ query }: { query: string }) => { - const items = [ - // --- 基础块 --- +export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] => { + const items: CommandItem[] = [ { title: "一级标题", - description: "Big section heading", + description: "大标题", group: "基础", icon: , shortcut: "Ctrl+Alt+1", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + }); }, }, { title: "二级标题", - description: "Medium section heading", + description: "中标题", group: "基础", icon: , shortcut: "Ctrl+Alt+2", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + }); }, }, { title: "三级标题", - description: "Small section heading", + description: "小标题", group: "基础", icon: , shortcut: "Ctrl+Alt+3", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + }); }, }, { - title: "普通文本", - description: "Just start writing with plain text", + title: "正文", + description: "普通段落", group: "基础", icon: , - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setParagraph().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setParagraph().run(); + }); }, }, - - // --- 列表 & 引用 --- { title: "无序列表", - description: "Create a simple bullet list", + description: "项目符号列表", group: "列表", icon: , shortcut: "-", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleBulletList().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }); }, }, { title: "有序列表", - description: "Create a numbered list", + description: "编号列表", group: "列表", icon: , shortcut: "1.", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }); }, }, { title: "任务列表", - description: "Track tasks", + description: "待办事项", group: "列表", icon: , shortcut: "Ctrl+L", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleTaskList().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }); }, }, { - title: "引述", - description: "Capture a quote", + title: "引用", + description: "引用块", group: "列表", icon: , shortcut: ">", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setBlockquote().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setBlockquote().run(); + }); }, }, - - // --- 插入 --- { title: "代码块", - description: "Capture a code snippet", + description: "插入代码段", group: "插入", icon: , shortcut: "```", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setCodeBlock().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setCodeBlock().run(); + }); }, }, { title: "分割线", - description: "Horizontal rule", + description: "横线", group: "插入", icon: , shortcut: "---", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + }); }, }, { - title: "高亮块 (Callout)", - description: "Callout box", + title: "高亮块(Callout)", + description: "提示框", group: "插入", icon: , - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setCallout().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setCallout().run(); + }); }, }, { title: "表格", - description: "Insert a 3x3 table", + description: "插入 3x3 表格", group: "插入", - icon:
T
, // Use generic icon if lucide missing or import specific - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + icon:
T
, + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }); }, }, { title: "图片", - description: "Insert image from URL", + description: "通过 URL 插入图片", group: "插入", icon:
I
, - command: ({ editor, range }: any) => { - const url = window.prompt('Image URL:'); + command: ({ editor, range }) => { + const url = window.prompt("图片 URL:"); if (url) { - editor.chain().focus().deleteRange(range).setImage({ src: url }).run(); + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setImage({ src: url }).run(); + }); } }, }, { title: "YouTube", - description: "Embed YouTube video", + description: "嵌入视频", group: "插入", icon:
Y
, - command: ({ editor, range }: any) => { - const url = window.prompt('YouTube URL:'); + command: ({ editor, range }) => { + const url = window.prompt("YouTube URL:"); if (url) { - editor.chain().focus().deleteRange(range).setYoutubeVideo({ src: url }).run(); + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setYoutubeVideo({ src: url }).run(); + }); } }, }, - - // --- 样式 --- { title: "粗体", - description: "Bold text", + description: "加粗", group: "样式", icon: , shortcut: "Ctrl+B", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleBold().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBold().run(); + }); }, }, { title: "斜体", - description: "Italic text", + description: "倾斜", group: "样式", icon: , shortcut: "Ctrl+I", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleItalic().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleItalic().run(); + }); }, }, { title: "下划线", - description: "Underline text", + description: "下划线", group: "样式", icon: , shortcut: "Ctrl+U", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleUnderline().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleUnderline().run(); + }); }, }, { title: "删除线", - description: "Strike text", + description: "中划线", group: "样式", icon: , shortcut: "Ctrl+Shift+S", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleStrike().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleStrike().run(); + }); }, }, { - title: "高亮 (Mark)", - description: "Highlight text", + title: "高亮标记", + description: "文本高亮", group: "样式", icon: , shortcut: "Alt+D", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).toggleHighlight().run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleHighlight().run(); + }); }, }, - - // --- 排版补充 --- { title: "四级标题", group: "基础", icon: , shortcut: "Ctrl+Alt+4", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run(); + }); }, }, { @@ -250,8 +302,10 @@ export const getSuggestionItems = ({ query }: { query: string }) => { group: "基础", icon: , shortcut: "Ctrl+Alt+5", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run(); + }); }, }, { @@ -259,8 +313,10 @@ export const getSuggestionItems = ({ query }: { query: string }) => { group: "基础", icon: , shortcut: "Ctrl+Alt+6", - command: ({ editor, range }: any) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run(); + command: ({ editor, range }) => { + run(editor, range, ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run(); + }); }, }, ]; @@ -268,12 +324,16 @@ export const getSuggestionItems = ({ query }: { query: string }) => { return items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())); }; +type CommandListHandle = { + onKeyDown: (props: { event: KeyboardEvent }) => boolean; +}; + export const renderSuggestionItems = () => { - let component: ReactRenderer; - let popup: TippyInstance[]; + let component: ReactRenderer; + let popup: TippyInstance | null = null; return { - onStart: (props: any) => { + onStart: (props: SuggestionProps) => { component = new ReactRenderer(CommandList, { props, editor: props.editor, @@ -283,9 +343,8 @@ export const renderSuggestionItems = () => { return; } - // @ts-ignore - popup = tippy("body", { - getReferenceClientRect: props.clientRect, + popup = tippy(document.body, { + getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), appendTo: () => document.body, content: component.element, showOnCreate: true, @@ -295,30 +354,30 @@ export const renderSuggestionItems = () => { }); }, - onUpdate: (props: any) => { + onUpdate: (props: SuggestionProps) => { component.updateProps(props); - if (!props.clientRect) { + if (!props.clientRect || !popup) { return; } - popup[0].setProps({ - getReferenceClientRect: props.clientRect, + popup.setProps({ + getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), }); }, - onKeyDown: (props: any) => { + onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { - popup[0].hide(); + popup?.hide(); return true; } - // @ts-ignore - return component.ref?.onKeyDown(props); + return component.ref?.onKeyDown(props) ?? false; }, onExit: () => { - popup?.[0]?.destroy(); + popup?.destroy(); + popup = null; component.destroy(); }, }; diff --git a/src/components/sidebar/tree-view.tsx b/src/components/sidebar/tree-view.tsx index 3691399..9b48a63 100644 --- a/src/components/sidebar/tree-view.tsx +++ b/src/components/sidebar/tree-view.tsx @@ -1,15 +1,28 @@ -"use client"; +"use client"; -import { Page, useEditorStore } from "@/lib/store"; -import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal, Trash2, FilePlus, FolderPlus, Download, Upload } from "lucide-react"; -import { cn } from "@/lib/utils"; import { useState } from "react"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export"; import { useRouter, usePathname } from "next/navigation"; -import { useImport } from "@/components/import-context"; import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { + ChevronRight, + FileText, + Folder, + FolderOpen, + MoreHorizontal, + Trash2, + FilePlus, + FolderPlus, + Download, + Upload, +} from "lucide-react"; + +import { Page, useEditorStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export"; +import { useImport } from "@/components/import-context"; +import { useConfirm } from "@/components/confirm-provider"; interface TreeViewProps { pages: Page[]; @@ -17,33 +30,32 @@ interface TreeViewProps { level?: number; } -function TreeNode({ node, pages, level, expanded, toggleExpand }: { - node: Page, - pages: Page[], - level: number, - expanded: Record, - toggleExpand: (id: string) => void +function TreeNode({ + node, + pages, + level, + expanded, + toggleExpand, +}: { + node: Page; + pages: Page[]; + level: number; + expanded: Record; + toggleExpand: (id: string) => void; }) { const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore(); const router = useRouter(); const pathname = usePathname(); const { triggerImport } = useImport(); + const confirm = useConfirm(); - const isFolder = node.type === 'folder'; - const hasChildren = pages.some(p => p.parentId === node.id); + const isFolder = node.type === "folder"; + const hasChildren = pages.some((p) => p.parentId === node.id); const isExpanded = expanded[node.id]; - // DnD Hooks - Sorting - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: node.id, - data: { type: node.type, title: node.title, parentId: node.parentId } + data: { type: node.type, title: node.title, parentId: node.parentId }, }); const style = { @@ -60,17 +72,16 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: { "group flex items-center justify-between px-2 py-1 text-sm rounded-md transition-colors cursor-pointer select-none border border-transparent", activePageId === node.id ? "bg-accent text-accent-foreground font-medium" - : "text-muted-foreground hover:bg-muted/50 hover:text-foreground", - // Use simple hover effect for "drop over" visual or rely on drag overlay + : "text-muted-foreground hover:bg-muted/50 hover:text-foreground" )} onClick={() => { setActivePageId(node.id); - if (pathname !== '/') router.push('/'); + if (pathname !== "/") router.push("/"); }} >