使用openai进行了代码review

This commit is contained in:
2026-02-24 11:33:08 +08:00
parent 9efdf060f5
commit 0c97f02e51
32 changed files with 1499 additions and 969 deletions
+3
View File
@@ -1,2 +1,5 @@
DATABASE_URL="file:./dev.db" DATABASE_URL="file:./dev.db"
SESSION_SECRET="replace-with-a-long-random-secret" 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"
+5
View File
@@ -9,10 +9,15 @@
```env ```env
DATABASE_URL="file:./dev.db" DATABASE_URL="file:./dev.db"
SESSION_SECRET="replace-with-a-long-random-secret" 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` 必填,用于服务端签名登录会话。 - `SESSION_SECRET` 必填,用于服务端签名登录会话。
- 生产环境请使用长度至少 32 的随机字符串。 - 生产环境请使用长度至少 32 的随机字符串。
- `INIT_DEFAULT_PASSWORD` 用于首次初始化密码(`/api/settings/init`)。
- 建议在生产环境设置 `INIT_SETUP_TOKEN`,避免未授权初始化。
## 开发 ## 开发
+3 -2
View File
@@ -6,7 +6,8 @@
"dev": "next dev -p 3001", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"check": "npm run lint -- --max-warnings=0 && npx tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -69,4 +70,4 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
} }
} }
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -13,10 +13,12 @@ async function hashPassword(password) {
} }
async function main() { async function main() {
const password = process.argv[2] || process.env.INIT_DEFAULT_PASSWORD || "admin";
const count = await prisma.globalSettings.count(); const count = await prisma.globalSettings.count();
if (count === 0) { if (count === 0) {
console.log("Initializing default settings..."); console.log("Initializing default settings...");
const hashedPassword = await hashPassword("admin"); const hashedPassword = await hashPassword(password);
await prisma.globalSettings.create({ await prisma.globalSettings.create({
data: { data: {
id: "default", id: "default",
+3 -2
View File
@@ -13,8 +13,9 @@ async function hashPassword(password) {
} }
async function main() { async function main() {
console.log("Resetting password to 'admin'..."); const password = process.argv[2] || process.env.INIT_DEFAULT_PASSWORD || "admin";
const hashedPassword = await hashPassword("admin"); console.log("Resetting password...");
const hashedPassword = await hashPassword(password);
const settings = await prisma.globalSettings.findFirst(); const settings = await prisma.globalSettings.findFirst();
if (settings) { if (settings) {
+39 -7
View File
@@ -1,20 +1,52 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
export const runtime = "edge"; // Optional: Use edge runtime for lower latency export const runtime = "edge"; // Optional: Use edge runtime for lower latency
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const authError = await requireApiAuth();
if (authError) return authError;
try { try {
const { messages, config } = await req.json(); const { messages, config } = await req.json();
const { apiKey, baseURL, model } = config || {}; const { apiKey, baseURL, model } = config || {};
if (!apiKey) { if (!apiKey || typeof apiKey !== "string") {
return NextResponse.json({ error: "Missing API Key" }, { status: 401 }); 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? let normalizedBaseUrl: string;
// Actually, usually users provide standard base URL "https://api.openai.com/v1" try {
// We should append /chat/completions. const parsed = new URL(baseURL);
const url = `${baseURL.replace(/\/$/, "")}/chat/completions`; 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, { const res = await fetch(url, {
method: "POST", method: "POST",
@@ -23,7 +55,7 @@ export async function POST(req: NextRequest) {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: model || "gpt-3.5-turbo", model: typeof model === "string" && model.trim() ? model.trim() : "gpt-3.5-turbo",
messages, messages,
stream: true, // Force streaming stream: true, // Force streaming
}), }),
+7 -2
View File
@@ -5,7 +5,10 @@ import { createSessionToken, getSessionCookieName, getSessionTtlSeconds } from "
export async function POST(req: Request) { export async function POST(req: Request) {
try { 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({ const settings = await prisma.globalSettings.findUnique({
where: { id: "default" }, where: { id: "default" },
@@ -18,7 +21,9 @@ export async function POST(req: Request) {
const isValid = await verifyPassword(password, settings.password); const isValid = await verifyPassword(password, settings.password);
if (isValid) { 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 token = await createSessionToken(days);
const response = NextResponse.json({ success: true }); const response = NextResponse.json({ success: true });
response.cookies.set(getSessionCookieName(), token, { response.cookies.set(getSessionCookieName(), token, {
+168 -13
View File
@@ -1,18 +1,87 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client'; 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<string | null, string[]> {
const childrenMap = new Map<string | null, string[]>();
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<string, string | null>(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<string>();
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( export async function GET(
request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const authError = await requireApiAuth();
if (authError) return authError;
const id = (await params).id; const id = (await params).id;
try { try {
const page = await prisma.page.findUnique({ const page = await prisma.page.findUnique({
where: { id }, where: { id },
}); });
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 }); 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 { } catch {
return NextResponse.json({ error: 'Error fetching page' }, { status: 500 }); return NextResponse.json({ error: 'Error fetching page' }, { status: 500 });
} }
@@ -22,23 +91,96 @@ export async function PUT(
request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const authError = await requireApiAuth();
if (authError) return authError;
const id = (await params).id; const id = (await params).id;
try { try {
const body = await request.json(); const body = await request.json().catch(() => null);
// Separate update logic for flexibility (e.g. only updating title) 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 = {}; const updateData: Prisma.PageUncheckedUpdateInput = {};
if (body.title !== undefined) updateData.title = body.title;
if (body.content !== undefined) updateData.content = body.content; if (body.title !== undefined) {
if (body.parentId !== undefined) updateData.parentId = body.parentId; if (typeof body.title !== 'string') {
if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags); return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
if (body.icon !== undefined) updateData.icon = body.icon; }
if (body.isLocked !== undefined) updateData.isLocked = body.isLocked; 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({ const page = await prisma.page.update({
where: { id }, where: { id },
data: updateData, data: updateData,
}); });
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
} catch { } catch {
return NextResponse.json({ error: 'Error updating page' }, { status: 500 }); return NextResponse.json({ error: 'Error updating page' }, { status: 500 });
} }
@@ -48,11 +190,24 @@ export async function DELETE(
request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const authError = await requireApiAuth();
if (authError) return authError;
const id = (await params).id; const id = (await params).id;
try { try {
await prisma.page.delete({ const refs = await prisma.page.findMany({ select: { id: true, parentId: true } });
where: { id }, 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 }); return NextResponse.json({ success: true });
} catch { } catch {
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 }); return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
+22
View File
@@ -1,7 +1,11 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { requireApiAuth } from '@/lib/api-auth';
export async function PUT(request: Request) { export async function PUT(request: Request) {
const authError = await requireApiAuth();
if (authError) return authError;
try { try {
const body = await request.json(); const body = await request.json();
const { updates } = body; const { updates } = body;
@@ -9,6 +13,24 @@ export async function PUT(request: Request) {
if (!Array.isArray(updates)) { if (!Array.isArray(updates)) {
return NextResponse.json({ error: 'Invalid updates' }, { status: 400 }); 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 // Transaction for batch update
await prisma.$transaction( await prisma.$transaction(
+75 -15
View File
@@ -1,14 +1,45 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; 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<string>();
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() { export async function GET() {
const authError = await requireApiAuth();
if (authError) return authError;
try { try {
const pages = await prisma.page.findMany({ const pages = await prisma.page.findMany({
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
}); });
const parsedPages = pages.map(p => ({ const parsedPages = pages.map((p) => ({
...p, ...p,
tags: JSON.parse(p.tags || "[]") tags: safeParseTags(p.tags),
})); }));
return NextResponse.json(parsedPages); return NextResponse.json(parsedPages);
} catch { } catch {
@@ -17,30 +48,59 @@ export async function GET() {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requireApiAuth();
if (authError) return authError;
try { try {
const body = await request.json(); const body = await request.json().catch(() => null);
const { title, content, parentId, type } = body; 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({ const page = await prisma.page.create({
data: { data: {
title: title || '无标题', title,
content: content || '', content,
tags: JSON.stringify(body.tags || []), tags: JSON.stringify(tags),
parentId: parentId || null, parentId,
type: type || 'file', type,
order: await (async () => { order: await (async () => {
if (body.order !== undefined) return body.order; if (requestedOrder !== undefined) return requestedOrder;
const lastPage = await prisma.page.findFirst({ const lastPage = await prisma.page.findFirst({
where: { parentId: parentId || null }, where: { parentId },
orderBy: { order: 'desc' }, orderBy: { order: 'desc' },
}); });
return (lastPage?.order ?? -1) + 1; return (lastPage?.order ?? -1) + 1;
})(), })(),
icon: body.icon || null, icon,
isLocked: body.isLocked || false, isLocked,
}, },
}); });
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") }); return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
} catch { } catch {
return NextResponse.json({ error: 'Error creating page' }, { status: 500 }); return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
} }
+31 -4
View File
@@ -1,16 +1,43 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth"; import { hashPassword } from "@/lib/auth";
export async function POST() { export async function POST(req: Request) {
try { try {
const count = await prisma.globalSettings.count(); const count = await prisma.globalSettings.count();
if (count > 0) { if (count > 0) {
return NextResponse.json({ message: "Settings already initialized" }, { status: 200 }); return NextResponse.json({ message: "Settings already initialized" }, { status: 200 });
} }
// Default password: "admin" const body = await req.json().catch(() => ({}));
const hashedPassword = await hashPassword("admin"); 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({ await prisma.globalSettings.create({
data: { data: {
id: "default", id: "default",
+14 -1
View File
@@ -1,10 +1,23 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword } from "@/lib/auth"; import { hashPassword, verifyPassword } from "@/lib/auth";
import { requireApiAuth } from "@/lib/api-auth";
export async function PUT(req: Request) { export async function PUT(req: Request) {
const authError = await requireApiAuth();
if (authError) return authError;
try { 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({ const settings = await prisma.globalSettings.findUnique({
where: { id: "default" }, where: { id: "default" },
+173 -167
View File
@@ -1,194 +1,200 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import JSZip from "jszip"; import JSZip from "jszip";
import { marked } from "marked"; 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 type ZipEntry = {
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; path: string;
const prisma = globalForPrisma.prisma || new PrismaClient(); isDir: boolean;
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; content?: string;
};
// Disable body parser strictly (Next.js App Router handles FormData naturally) type ParsedMarkdown = {
// export const config = { title: string;
// api: { order: number;
// bodyParser: false, tags: string[];
// }, htmlContent: string;
// }; };
// No need for config in App Router route handlers.
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<ParsedMarkdown> {
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<string>();
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) { export async function POST(req: NextRequest) {
const authError = await requireApiAuth();
if (authError) return authError;
try { try {
const formData = await req.formData(); 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 }); 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 buffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(buffer); const zip = await JSZip.loadAsync(buffer);
const entries: ZipEntry[] = [];
const loadTasks: Promise<void>[] = [];
// Map to store directory paths to their new DB IDs zip.forEach((rawPath, zipEntry) => {
// Data format: "folder/subfolder" -> UUID if (rawPath.startsWith("__MACOSX") || rawPath.includes(".DS_Store")) return;
const pathIdMap = new Map<string, string>();
// Prepare data for proper insertion order (Folders first, then files?) loadTasks.push((async () => {
// Actually, we need to process by path depth to ensure parents exist. const path = normalizeZipPath(rawPath);
const entries: Array<{ path: string; isDir: boolean; content?: string }> = []; if (!path) return;
// 1. Read all entries
const filePromises: Promise<void>[] = [];
zip.forEach((relativePath, zipEntry) => {
if (relativePath.startsWith("__MACOSX") || relativePath.includes(".DS_Store")) {
return; // Skip system files
}
const promise = (async () => {
if (zipEntry.dir) { if (zipEntry.dir) {
// Remove trailing slash for consistency entries.push({ path, isDir: true });
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; return;
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 });
}
} }
})(); if (!path.endsWith(".md")) return;
filePromises.push(promise);
const content = await zipEntry.async("string");
entries.push({ path, isDir: false, content });
})());
}); });
await Promise.all(filePromises); await Promise.all(loadTasks);
if (entries.length === 0) {
// 2. Clear existing Pages (Transaction usually) return NextResponse.json({ error: "No valid markdown entries found in zip" }, { status: 400 });
// 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<string | null> => {
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 <br> (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
}
});
}
} }
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) { // Parse markdown before DB transaction to keep lock time low.
console.error("Restore failed:", e); const parsedFiles = await Promise.all(
return NextResponse.json({ error: "Restore failed: " + String(e) }, { status: 500 }); 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<string, string>();
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 });
} }
} }
+41 -45
View File
@@ -5,62 +5,58 @@
@layer base { @layer base {
:root { :root {
--background: 240 10% 93%; --background: 38 20% 95%;
/* Main content: 93% gray */ /* Softer warm gray background */
--foreground: 240 5% 20%; --foreground: 225 11% 22%;
--card: 0 0% 100%; --card: 40 17% 98%;
--card-foreground: 240 5% 15%; --card-foreground: 225 11% 22%;
--popover: 0 0% 100%; --popover: 40 17% 98%;
--popover-foreground: 240 5% 15%; --popover-foreground: 225 11% 22%;
--primary: 240 5.9% 10%; --primary: 223 21% 20%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
--secondary: 240 10% 90%; --secondary: 36 18% 92%;
/* Sidebar: 90% gray */ --secondary-foreground: 223 18% 24%;
--secondary-foreground: 240 5.9% 10%; --muted: 36 16% 93%;
--muted: 240 4.8% 96%; --muted-foreground: 223 9% 42%;
--muted-foreground: 240 3.8% 46%; --accent: 34 20% 91%;
--accent: 240 4.8% 96%; --accent-foreground: 223 18% 24%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%; --border: 35 15% 85%;
--input: 240 5.9% 90%; --input: 35 15% 85%;
--ring: 240 5.9% 10%; --ring: 223 21% 30%;
--radius: 0.75rem; --radius: 0.75rem;
} }
.dark { .dark {
/* Notion-like Dark Mode (Inverted Hierarchy) - Deepened */ /* Softer dark mode: less pure black, gentler contrast */
--background: 0 0% 9%; --background: 220 10% 13%;
/* Main Content: Deep Dark (#171717) */ --foreground: 210 16% 90%;
--foreground: 0 0% 92%;
--card: 0 0% 9%;
/* Match background */
--card-foreground: 0 0% 92%;
--popover: 0 0% 9%; --card: 220 10% 15%;
--popover-foreground: 0 0% 92%; --card-foreground: 210 16% 90%;
--primary: 0 0% 92%; --popover: 220 10% 16%;
--primary-foreground: 0 0% 10%; --popover-foreground: 210 16% 90%;
--secondary: 0 0% 13%; --primary: 210 16% 90%;
/* Sidebar: Lighter than main (#212121), but deeper than before */ --primary-foreground: 220 12% 14%;
--secondary-foreground: 0 0% 92%;
--muted: 0 0% 13%; --secondary: 220 10% 18%;
--muted-foreground: 0 0% 65%; --secondary-foreground: 210 16% 90%;
--accent: 0 0% 13%; --muted: 220 10% 20%;
--accent-foreground: 0 0% 92%; --muted-foreground: 210 10% 72%;
--destructive: 0 62.8% 30.6%; --accent: 220 10% 22%;
--destructive-foreground: 0 0% 92%; --accent-foreground: 210 16% 92%;
--border: 0 0% 18%; --destructive: 0 62.8% 35%;
/* Subtle borders */ --destructive-foreground: 0 0% 96%;
--input: 0 0% 18%;
--ring: 0 0% 80%; --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 */ /* Override Highlight.js background for a softer look */
.ProseMirror pre { .ProseMirror pre {
background: #252529 !important; background: #2b313a !important;
/* Softer dark gray (hsl(240 5% 15%)), approx matching foreground */ /* Slightly lifted code block background for comfortable reading */
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.hljs { .hljs {
background: transparent !important; background: transparent !important;
/* Let pre handle the background */ /* Let pre handle the background */
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { ConfirmProvider } from "@/components/confirm-provider";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@@ -24,7 +25,7 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{children} <ConfirmProvider>{children}</ConfirmProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+11 -11
View File
@@ -30,10 +30,10 @@ export default function LoginPage() {
router.push("/"); router.push("/");
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "Login failed"); setError(data.error || "登录失败");
} }
} catch { } catch {
setError("Something went wrong. Please try again."); setError("发生错误,请重试");
} }
}; };
@@ -44,8 +44,8 @@ export default function LoginPage() {
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4">
<Sparkles size={32} /> <Sparkles size={32} />
</div> </div>
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1> <h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">Enter your access password to continue.</p> <p className="text-muted-foreground">访</p>
</div> </div>
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4">
@@ -54,7 +54,7 @@ export default function LoginPage() {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<input <input
type="password" type="password"
placeholder="Access password" placeholder="访问密码"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => 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" 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)} onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50" className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50"
/> />
Remember me
</label> </label>
{rememberMe && ( {rememberMe && (
@@ -81,9 +81,9 @@ export default function LoginPage() {
onChange={(e) => setDuration(e.target.value)} onChange={(e) => setDuration(e.target.value)}
className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs" className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs"
> >
<option value="1">1 day</option> <option value="1">1</option>
<option value="7">7 days</option> <option value="7">7</option>
<option value="30">30 days</option> <option value="30">30</option>
</select> </select>
)} )}
</div> </div>
@@ -92,12 +92,12 @@ export default function LoginPage() {
type="submit" 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" 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
</button> </button>
</form> </form>
<div className="text-center text-xs text-muted-foreground"> <div className="text-center text-xs text-muted-foreground">
NoteAI - Your private second brain NoteAI -
</div> </div>
</div> </div>
</div> </div>
+246 -384
View File
@@ -1,35 +1,41 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store"; import { Lock, Save, Sparkles, Type, Upload, Download } from "lucide-react";
import { Lock, Type, Save, Sparkles } from "lucide-react";
import { ResizableSidebar } from "@/components/sidebar"; import { ResizableSidebar } from "@/components/sidebar";
import { PromptManagement } from "@/components/settings/prompt-management"; import { PromptManagement } from "@/components/settings/prompt-management";
import { ImportProvider } from "@/components/import-context"; import { ImportProvider } from "@/components/import-context";
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
import { useConfirm } from "@/components/confirm-provider";
export default function SettingsPage() { export default function SettingsPage() {
// Appearance const confirm = useConfirm();
const { const {
fontSize, setFontSize, fontSize,
fontFamily, setFontFamily, setFontSize,
lineHeight, setLineHeight, fontFamily,
tableLineHeight, setTableLineHeight, setFontFamily,
timezone, setTimezone, lineHeight,
aiConfig, setAIConfig, setLineHeight,
tableLineHeight,
setTableLineHeight,
timezone,
setTimezone,
aiConfig,
setAIConfig,
} = useSettingsStore(); } = useSettingsStore();
// Security
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = 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) => { const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMsg(null); setMsg(null);
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setMsg({ type: 'error', text: "两次输入的新密码不一致" }); setMsg({ type: "error", text: "两次输入的新密码不一致" });
return; return;
} }
@@ -41,412 +47,268 @@ export default function SettingsPage() {
}); });
if (res.ok) { if (res.ok) {
setMsg({ type: 'success', text: "密码修改成功" }); setMsg({ type: "success", text: "密码修改成功" });
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");
} else { } else {
const data = await res.json(); const data = await res.json();
setMsg({ type: 'error', text: data.error || "修改失败" }); setMsg({ type: "error", text: data.error || "修改失败" });
} }
} catch { } catch {
setMsg({ type: 'error', text: "系统错误,请重试" }); setMsg({ type: "error", text: "系统错误,请重试" });
} }
}; };
return ( return (
<ImportProvider> <ImportProvider>
<div className="flex h-screen w-full bg-background overflow-hidden relative"> <div className="flex h-screen w-full bg-background overflow-hidden">
<ResizableSidebar /> <ResizableSidebar />
<main className="flex-1 h-full flex overflow-hidden bg-background/50"> <main className="flex-1 h-full overflow-y-auto p-4 md:p-10">
{/* Settings Navigation Sidebar (Desktop Only) */} <div className="mx-auto max-w-3xl space-y-8 pb-16">
<aside className="w-56 lg:w-64 border-r bg-background/30 hidden md:flex flex-col p-6 overflow-y-auto"> <header className="space-y-2">
<div className="mb-6"> <h1 className="text-3xl font-bold tracking-tight"></h1>
<h1 className="text-2xl font-bold tracking-tight"></h1> <p className="text-muted-foreground">AI</p>
<p className="text-sm text-muted-foreground mt-1"></p> </header>
</div>
<nav className="space-y-1">
<button
onClick={() => document.getElementById('appearance')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<Type size={16} />
<span></span>
</button>
<button
onClick={() => document.getElementById('ai-config')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<Sparkles size={16} />
<span>AI </span>
</button>
<button
onClick={() => document.getElementById('prompts')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><line x1="9" x2="15" y1="9" y2="15" /><line x1="15" x2="9" y1="9" y2="15" /></svg>
<span></span>
</button>
<button
onClick={() => document.getElementById('security')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<Lock size={16} />
<span></span>
</button>
<button
onClick={() => document.getElementById('backup')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></svg>
<span></span>
</button>
</nav>
</aside>
{/* Settings Content Area */} <section className="rounded-xl border bg-card p-6 space-y-5">
<div className="flex-1 h-full overflow-y-auto p-4 md:p-12 scroll-smooth"> <div className="flex items-center gap-2 border-b pb-2">
<div className="max-w-3xl mx-auto space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20"> <Type size={18} className="text-primary" />
{/* Mobile Header (Hidden on Desktop) */} <h2 className="text-xl font-semibold"></h2>
<div className="md:hidden">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-2"></p>
</div> </div>
{/* Appearance Section */} <div className="grid gap-4 md:grid-cols-2">
<section id="appearance" className="bg-card border shadow-sm rounded-xl p-6 space-y-6"> <label className="space-y-2 text-sm">
<div className="flex items-center gap-2 pb-2 border-b"> <span className="font-medium"></span>
<Type size={20} className="text-primary" /> <select
<h2 className="text-xl font-semibold"></h2> value={fontFamily}
</div> onChange={(e) => setFontFamily(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-3">
<label className="text-sm font-medium"></label>
<select
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all"
value={fontFamily}
onChange={(e) => setFontFamily(e.target.value)}
>
{fontOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"> ({fontSize}px)</label>
<div className="flex items-center gap-4">
<input
type="range"
min="12"
max="32"
step="1"
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{fontSize}</span>
</div>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"> ({lineHeight})</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1.0"
max="3.0"
step="0.1"
value={lineHeight}
onChange={(e) => setLineHeight(parseFloat(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{lineHeight}</span>
</div>
</div>
<div className="space-y-3">
<label className="text-sm font-medium"> ({tableLineHeight})</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1.0"
max="3.0"
step="0.1"
value={tableLineHeight}
onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{tableLineHeight}</span>
</div>
</div>
{/* Timezone takes full width or 2 cols */}
<div className="md:col-span-2 space-y-3">
<label className="text-sm font-medium">Timezone ()</label>
<div className="grid md:grid-cols-2 gap-4">
<select
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
>
{timezoneOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Future placeholder for more timezone settings or info */}
<div className="hidden md:block"></div>
</div>
</div>
</div>
<div className="p-6 bg-muted/30 border rounded-xl">
<p className="text-sm text-muted-foreground mb-2">:</p>
<div
style={{ fontFamily, fontSize: `${fontSize}px` }}
className="leading-relaxed transition-all"
> >
NoteAI {fontOptions.map((opt) => (
</div> <option key={opt.value} value={opt.value}>
</div> {opt.label}
</section> </option>
))}
</select>
</label>
{/* AI Configuration Section */} <label className="space-y-2 text-sm">
<section id="ai-config" className="bg-card border shadow-sm rounded-xl p-6 space-y-6"> <span className="font-medium">: {fontSize}px</span>
<div className="flex items-center gap-2 pb-2 border-b"> <input
<Sparkles size={20} className="text-primary" /> type="range"
<h2 className="text-xl font-semibold">AI </h2> min="12"
</div> max="32"
step="1"
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
className="w-full"
/>
</label>
<div className="space-y-4 max-w-xl"> <label className="space-y-2 text-sm">
<div className="space-y-2"> <span className="font-medium">: {lineHeight}</span>
<label className="text-sm font-medium">API Base URL</label> <input
<input type="range"
type="text" min="1"
value={aiConfig.baseURL} max="3"
onChange={(e) => setAIConfig({ baseURL: e.target.value })} step="0.1"
placeholder="https://api.openai.com/v1" value={lineHeight}
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" onChange={(e) => setLineHeight(parseFloat(e.target.value))}
/> className="w-full"
<p className="text-xs text-muted-foreground"> OpenAI </p> />
</div> </label>
<div className="space-y-2"> <label className="space-y-2 text-sm">
<label className="text-sm font-medium">API Key</label> <span className="font-medium">: {tableLineHeight}</span>
<input <input
type="password" type="range"
value={aiConfig.apiKey} min="1"
onChange={(e) => setAIConfig({ apiKey: e.target.value })} max="3"
placeholder="sk-..." step="0.1"
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" value={tableLineHeight}
/> onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
</div> className="w-full"
/>
<div className="space-y-2"> </label>
<label className="text-sm font-medium">Model Name</label>
<input
type="text"
value={aiConfig.model}
onChange={(e) => 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"
/>
</div>
<div className="pt-2">
<button
onClick={async () => {
const btn = document.activeElement as HTMLButtonElement;
const originalText = btn.innerText;
btn.innerText = "Testing...";
btn.disabled = true;
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: aiConfig, // Use current config from store
messages: [{ role: "user", content: "Hi" }]
})
});
if (res.ok) {
const text = await res.text();
// Simple check if we got a stream or text
if (text.length > 0) {
alert("连接成功!API 返回正常。");
} else {
alert("连接成功,但没有返回内容。");
}
} else {
alert(`连接失败: ${res.status} ${res.statusText}`);
}
} catch (e) {
alert("连接出错: " + String(e));
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}}
className="px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg text-sm font-medium transition-colors"
>
</button>
</div>
</div>
</section>
{/* Prompt Management Section */}
<div id="prompts" className="bg-card border shadow-sm rounded-xl p-6">
<PromptManagement />
</div> </div>
{/* Security Section */} <label className="space-y-2 text-sm block">
<section id="security" className="bg-card border shadow-sm rounded-xl p-6 space-y-6"> <span className="font-medium"></span>
<div className="flex items-center gap-2 pb-2 border-b"> <select
<Lock size={20} className="text-primary" /> value={timezone}
<h2 className="text-xl font-semibold"></h2> onChange={(e) => setTimezone(e.target.value)}
</div> className="w-full rounded-lg border bg-muted/40 px-3 py-2"
>
{timezoneOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</section>
<form onSubmit={handlePasswordChange} className="space-y-4 max-w-md"> <section className="rounded-xl border bg-card p-6 space-y-5">
<div className="space-y-2"> <div className="flex items-center gap-2 border-b pb-2">
<label className="text-sm font-medium"></label> <Sparkles size={18} className="text-primary" />
<input <h2 className="text-xl font-semibold">AI </h2>
type="password" </div>
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} <div className="space-y-3">
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50" <label className="space-y-1 text-sm block">
required <span className="font-medium">API Base URL</span>
/> <input
</div> type="text"
<div className="space-y-2"> value={aiConfig.baseURL}
<label className="text-sm font-medium"></label> onChange={(e) => setAIConfig({ baseURL: e.target.value })}
<input className="w-full rounded-lg border bg-muted/40 px-3 py-2"
type="password" placeholder="https://api.openai.com/v1"
value={newPassword} />
onChange={(e) => setNewPassword(e.target.value)} </label>
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
required <label className="space-y-1 text-sm block">
/> <span className="font-medium">API Key</span>
</div> <input
<div className="space-y-2"> type="password"
<label className="text-sm font-medium"></label> value={aiConfig.apiKey}
<input onChange={(e) => setAIConfig({ apiKey: e.target.value })}
type="password" className="w-full rounded-lg border bg-muted/40 px-3 py-2"
value={confirmPassword} placeholder="sk-..."
onChange={(e) => 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" </label>
required
/> <label className="space-y-1 text-sm block">
<span className="font-medium">Model</span>
<input
type="text"
value={aiConfig.model}
onChange={(e) => setAIConfig({ model: e.target.value })}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
placeholder="gpt-4o-mini"
/>
</label>
</div>
</section>
<section className="rounded-xl border bg-card p-6">
<PromptManagement />
</section>
<section className="rounded-xl border bg-card p-6 space-y-5">
<div className="flex items-center gap-2 border-b pb-2">
<Lock size={18} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
</div>
<form onSubmit={handlePasswordChange} className="space-y-3 max-w-md">
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
placeholder="当前密码"
required
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
placeholder="新密码"
required
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
placeholder="确认新密码"
required
/>
{msg && (
<div className={msg.type === "success" ? "rounded-md bg-green-500/10 p-3 text-sm text-green-600" : "rounded-md bg-red-500/10 p-3 text-sm text-red-600"}>
{msg.text}
</div> </div>
)}
{msg && ( <button
<div className={`p-3 rounded-lg text-sm ${msg.type === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'}`}> type="submit"
{msg.text} className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
</div> >
)} <Save size={14} />
</button>
</form>
</section>
<button <section className="rounded-xl border bg-card p-6 space-y-4">
type="submit" <h2 className="text-xl font-semibold"></h2>
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" <p className="text-sm text-muted-foreground"></p>
>
<Save size={16} />
</button>
</form>
</section>
{/* Data Management Section */} <div className="flex flex-col gap-3 sm:flex-row">
<section id="backup" className="bg-card border shadow-sm rounded-xl p-6 space-y-6"> <button
<div className="flex items-center gap-2 pb-2 border-b"> onClick={async () => {
<span className="text-xl font-semibold">📦 </span> const { pages } = await import("@/lib/store").then((m) => m.useEditorStore.getState());
</div> const { exportAllPagesAsZip } = await import("@/lib/export");
await exportAllPagesAsZip(pages);
}}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
>
<Download size={14} />
(Zip)
</button>
<div className="p-4 border rounded-lg bg-orange-50/50 dark:bg-orange-900/10 border-orange-200 dark:border-orange-800/30"> <label className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/20">
<h3 className="font-medium text-orange-800 dark:text-orange-300 mb-2 flex items-center gap-2"> <Upload size={14} />
</h3> <input
<ul className="list-disc list-inside text-sm text-orange-700 dark:text-orange-400/80 space-y-1"> type="file"
<li> Markdown (Zip)</li> accept=".zip"
<li><b></b></li> className="hidden"
</ul> onChange={async (e) => {
</div> const file = e.target.files?.[0];
if (!file) return;
const ok = await confirm({
title: "确认恢复备份",
description: "该操作将覆盖当前所有文档,且无法撤销。",
confirmText: "继续恢复",
cancelText: "取消",
tone: "danger",
});
if (!ok) {
e.target.value = "";
return;
}
<div className="flex flex-col sm:flex-row gap-4">
<button
onClick={async () => {
const { pages } = await import('@/lib/store').then(m => m.useEditorStore.getState());
const { exportAllPagesAsZip } = await import('@/lib/export');
try { try {
await exportAllPagesAsZip(pages); const formData = new FormData();
} catch (e) { formData.append("file", file);
alert("备份失败: " + String(e)); const res = await fetch("/api/settings/restore", { method: "POST", body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "恢复失败");
}
window.location.reload();
} catch (error) {
alert(String(error));
} finally {
e.target.value = "";
} }
}} }}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg font-medium transition-colors" />
> </label>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
(Zip)
</button>
<div className="relative">
<input
type="file"
accept=".zip"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!confirm("⚠️ 警告:此操作将永久删除当前所有文档并恢复备份!\n\n确定要继续吗?")) {
e.target.value = ''; // Reset
return;
}
try {
const formData = new FormData();
formData.append("file", file);
const btn = e.target.parentElement?.querySelector('button');
if (btn) btn.innerText = "恢复中...";
const res = await fetch("/api/settings/restore", {
method: "POST",
body: formData
});
if (res.ok) {
alert("恢复成功!页面将刷新。");
window.location.reload();
} else {
const err = await res.json();
alert("恢复失败: " + (err.error || res.statusText));
}
} catch (error) {
alert("系统错误: " + String(error));
} finally {
if (e.target) e.target.value = '';
const btn = e.target.parentElement?.querySelector('button');
if (btn) btn.innerText = "上传备份并恢复";
}
}}
/>
<button className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2.5 bg-destructive/10 text-destructive hover:bg-destructive/20 rounded-lg font-medium transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></svg>
</button>
</div>
</div>
</section>
{/* Version Info */}
<div className="py-8 text-center text-xs text-muted-foreground/60">
<p>NoteAI v{process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0'}</p>
</div> </div>
</section>
<div className="pt-6 text-center text-xs text-muted-foreground/70">
NoteAI v{process.env.NEXT_PUBLIC_APP_VERSION || "0.1.0"}
</div> </div>
</div> </div>
</main > </main>
</div > </div>
</ImportProvider> </ImportProvider>
); );
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/lib/settings-store"; import { useSettingsStore } from "@/lib/settings-store";
import { Editor } from "@tiptap/react"; import { Editor } from "@tiptap/react";
import { marked } from "marked"; import { marked } from "marked";
import { sanitizeHtml } from "@/lib/sanitize-html";
interface Message { interface Message {
id: string; id: string;
@@ -189,7 +190,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
{msg.role === "assistant" ? ( {msg.role === "assistant" ? (
<div <div
className="prose dark:prose-invert prose-sm max-w-none break-words [&>p]:mb-2 [&>ul]:list-disc [&>ul]:pl-4 [&>ol]:list-decimal [&>ol]:pl-4" className="prose dark:prose-invert prose-sm max-w-none break-words [&>p]:mb-2 [&>ul]:list-disc [&>ul]:pl-4 [&>ol]:list-decimal [&>ol]:pl-4"
dangerouslySetInnerHTML={{ __html: marked.parse(msg.content) as string }} dangerouslySetInnerHTML={{ __html: sanitizeHtml(marked.parse(msg.content) as string) }}
/> />
) : ( ) : (
<p className="whitespace-pre-wrap">{msg.content}</p> <p className="whitespace-pre-wrap">{msg.content}</p>
+97
View File
@@ -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<boolean>;
const ConfirmContext = React.createContext<ConfirmFn | null>(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<ConfirmRequest | null>(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<ConfirmFn>((options) => {
return new Promise<boolean>((resolve) => {
setRequest({ ...options, resolve });
setOpen(true);
});
}, []);
return (
<ConfirmContext.Provider value={confirm}>
{children}
<Dialog.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) closeWith(false);
setOpen(nextOpen);
}}
>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-[100] bg-black/55 backdrop-blur-[1px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-[101] w-[92vw] max-w-[460px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border/70 bg-background p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
<Dialog.Title className="text-xl font-semibold tracking-tight text-foreground">
{request?.title}
</Dialog.Title>
{request?.description && (
<Dialog.Description className="mt-2 text-sm leading-relaxed text-muted-foreground">
{request.description}
</Dialog.Description>
)}
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={() => closeWith(false)}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted"
>
{request?.cancelText || "取消"}
</button>
<button
type="button"
onClick={() => closeWith(true)}
className={
request?.tone === "danger"
? "rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-opacity hover:opacity-90"
: "rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
}
>
{request?.confirmText || "确认"}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</ConfirmContext.Provider>
);
}
+43 -54
View File
@@ -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 StarterKit from "@tiptap/starter-kit";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { AIAssist } from "./ai-assist"; import { AIAssist } from "./ai-assist";
@@ -8,10 +8,9 @@ import { Sparkles } from "lucide-react";
import { Toolbar } from "./editor/toolbar"; import { Toolbar } from "./editor/toolbar";
import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./editor/slash-command"; import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./editor/slash-command";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from 'lowlight'; import { lowlight } from "lowlight";
import { CodeBlockComponent } from "./editor/code-block"; import { CodeBlockComponent } from "./editor/code-block";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import Underline from "@tiptap/extension-underline"; import Underline from "@tiptap/extension-underline";
import Subscript from "@tiptap/extension-subscript"; import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript"; import Superscript from "@tiptap/extension-superscript";
@@ -21,7 +20,7 @@ import TaskItem from "@tiptap/extension-task-item";
import { Callout } from "./editor/extensions/callout"; import { Callout } from "./editor/extensions/callout";
import { AIMark } from "./editor/extensions/ai-mark"; import { AIMark } from "./editor/extensions/ai-mark";
import { TaskItemComponent } from "./editor/extensions/task-item"; 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 { Table } from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row"; import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell"; import TableCell from "@tiptap/extension-table-cell";
@@ -30,12 +29,12 @@ import Image from "@tiptap/extension-image";
import Youtube from "@tiptap/extension-youtube"; import Youtube from "@tiptap/extension-youtube";
import TextAlign from "@tiptap/extension-text-align"; import TextAlign from "@tiptap/extension-text-align";
import Gapcursor from "@tiptap/extension-gapcursor"; import Gapcursor from "@tiptap/extension-gapcursor";
import { Markdown } from 'tiptap-markdown'; import { Markdown } from "tiptap-markdown";
interface EditorProps { interface EditorProps {
content: string; content: string;
onChange: (content: string) => void; onChange: (content: string) => void;
onEditorReady?: (editor: any) => void; onEditorReady?: (editor: TiptapEditor) => void;
onToggleAI?: () => void; onToggleAI?: () => void;
onExport?: () => void; onExport?: () => void;
editable?: boolean; editable?: boolean;
@@ -45,7 +44,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
const [showAI, setShowAI] = useState(false); const [showAI, setShowAI] = useState(false);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const { fontFamily, fontSize } = useSettingsStore(); const { fontFamily, fontSize, lineHeight, tableLineHeight } = useSettingsStore();
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -70,17 +69,15 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
render: renderSuggestionItems, render: renderSuggestionItems,
}, },
}), }),
CodeBlockLowlight CodeBlockLowlight.extend({
.extend({ addNodeView() {
addNodeView() { return ReactNodeViewRenderer(CodeBlockComponent);
return ReactNodeViewRenderer(CodeBlockComponent) },
} }).configure({ lowlight, defaultLanguage: "plaintext" }),
})
.configure({ lowlight, defaultLanguage: 'plaintext' }),
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
HTMLAttributes: { 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, Underline,
@@ -94,8 +91,8 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
nested: true, nested: true,
}).extend({ }).extend({
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(TaskItemComponent) return ReactNodeViewRenderer(TaskItemComponent);
} },
}), }),
Callout, Callout,
AIMark, AIMark,
@@ -113,15 +110,15 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
controls: false, controls: false,
}), }),
TextAlign.configure({ TextAlign.configure({
types: ['heading', 'paragraph'], types: ["heading", "paragraph"],
}), }),
Markdown.configure({ Markdown.configure({
html: true, // Allow HTML input/output html: true,
transformPastedText: true, // Auto-transform pasted markdown transformPastedText: true,
transformCopiedText: true, // Auto-transform copied markdown transformCopiedText: true,
}) }),
], ],
content: content, content,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
if (editor.getHTML() !== content) { if (editor.getHTML() !== content) {
onChange(editor.getHTML()); onChange(editor.getHTML());
@@ -130,11 +127,11 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
editorProps: { editorProps: {
attributes: { attributes: {
class: "prose prose-zinc dark:prose-invert max-w-none focus:outline-none min-h-[300px]", 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", spellcheck: "false",
}, },
}, },
editable: editable, editable,
immediatelyRender: false, immediatelyRender: false,
}); });
@@ -163,7 +160,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
setIsGenerating(false); setIsGenerating(false);
}; };
const handleAISuggest = async (prompt: any) => { const handleAISuggest = async (prompt: AIPrompt) => {
if (!editor) return; if (!editor) return;
const { aiConfig } = useSettingsStore.getState(); const { aiConfig } = useSettingsStore.getState();
@@ -175,8 +172,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, " "); const selectedText = editor.state.doc.textBetween(from, to, " ");
// Custom prompt logic const systemPrompt = prompt.systemPrompt || "你是一个乐于助人的助手。";
const systemPrompt = prompt.systemPrompt || "You are a helpful assistant.";
let userPrompt = selectedText; let userPrompt = selectedText;
if (!userPrompt) { if (!userPrompt) {
@@ -196,10 +192,10 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
config: aiConfig, config: aiConfig,
messages: [ messages: [
{ role: "system", content: systemPrompt }, { 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) { if (!response.ok || !response.body) {
@@ -209,33 +205,33 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
editor.chain().focus().insertContent("\n\n").toggleMark('aiMark').run(); editor.chain().focus().insertContent("\n\n").toggleMark("aiMark").run();
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value); const chunk = decoder.decode(value);
const lines = chunk.split('\n'); const lines = chunk.split("\n");
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') { if (line.startsWith("data: ") && line !== "data: [DONE]") {
try { try {
const data = JSON.parse(line.slice(6)); 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) { if (content) {
editor.commands.insertContent(content); editor.commands.insertContent(content);
} }
} catch { } catch {
// ignore // Ignore malformed stream chunk.
} }
} }
} }
} }
editor.chain().focus().insertContent("\n\n").unsetMark('aiMark').run(); editor.chain().focus().insertContent("\n\n").unsetMark("aiMark").run();
} catch (e: unknown) {
} catch (e: any) { if (e instanceof Error && e.name === "AbortError") {
if (e.name === 'AbortError') { editor.chain().focus().insertContent(" [已停止]").unsetMark("aiMark").run();
editor.chain().focus().insertContent(" [已停止]").unsetMark('aiMark').run();
} else { } else {
console.error("AI Error", e); console.error("AI Error", e);
alert("AI 请求失败,请检查配置或网络"); alert("AI 请求失败,请检查配置或网络");
@@ -254,11 +250,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
{showAI && ( {showAI && (
<div className="absolute top-12 right-4 z-50"> <div className="absolute top-12 right-4 z-50">
<AIAssist <AIAssist isOpen={showAI} onSuggest={handleAISuggest} onClose={() => setShowAI(false)} />
isOpen={showAI}
onSuggest={handleAISuggest}
onClose={() => setShowAI(false)}
/>
</div> </div>
)} )}
@@ -275,16 +267,13 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
setShowAI(!showAI); setShowAI(!showAI);
} }
}} }}
className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${isGenerating className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${
? "bg-red-500 text-white animate-pulse" isGenerating ? "bg-red-500 text-white animate-pulse" : "bg-primary text-primary-foreground"
: "bg-primary text-primary-foreground" }`}
}`} title={isGenerating ? "停止生成" : "AI 助手"}
title={isGenerating ? "停止生成 (Stop)" : "AI 助手 (AI Assist)"}
> >
{isGenerating ? <div className="h-5 w-5 bg-current rounded-sm" /> : <Sparkles size={20} />} {isGenerating ? <div className="h-5 w-5 bg-current rounded-sm" /> : <Sparkles size={20} />}
</button> </button>
</div> </div>
); );
} }
+5 -4
View File
@@ -13,6 +13,7 @@ export function CodeBlockComponent({
editor, editor,
getPos, getPos,
}: NodeViewProps) { }: NodeViewProps) {
const codeBg = "#2b313a";
const { language: defaultLanguage } = node.attrs; const { language: defaultLanguage } = node.attrs;
const { textContent } = node; const { textContent } = node;
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
@@ -70,7 +71,7 @@ export function CodeBlockComponent({
}, [textContent]); }, [textContent]);
return ( return (
<NodeViewWrapper className="relative group code-block rounded-lg border border-border/40 bg-[#252529] my-4 shadow-sm overflow-hidden"> <NodeViewWrapper className="relative group code-block rounded-lg border border-border/40 my-4 shadow-sm overflow-hidden" style={{ backgroundColor: codeBg }}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-[#2f2f35] border-b border-white/5 text-xs select-none text-zinc-400"> <div className="flex items-center justify-between px-3 py-2 bg-[#2f2f35] border-b border-white/5 text-xs select-none text-zinc-400">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -126,10 +127,10 @@ export function CodeBlockComponent({
</div> </div>
{/* Code Area with Line Numbers - Use grid for better alignment control */} {/* Code Area with Line Numbers - Use grid for better alignment control */}
<div className="relative grid grid-cols-[auto_1fr] bg-[#252529] font-mono text-sm leading-6"> <div className="relative grid grid-cols-[auto_1fr] font-mono text-sm leading-6" style={{ backgroundColor: codeBg }}>
{/* Line Numbers Gutter */} {/* Line Numbers Gutter */}
<div <div
className="py-4 px-2 text-right select-none border-r border-white/5 bg-[#252529] text-zinc-500" className="py-4 px-2 text-right select-none border-r border-white/5 text-zinc-500"
style={{ minWidth: '2.5rem' }} style={{ minWidth: '2.5rem' }}
contentEditable={false} contentEditable={false}
> >
@@ -139,7 +140,7 @@ export function CodeBlockComponent({
</div> </div>
{/* Actual Code Content */} {/* Actual Code Content */}
<pre className="!bg-transparent overflow-x-auto !p-0 !my-0 !border-0 text-zinc-300 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent"> <pre className="overflow-x-auto !p-0 !my-0 !border-0 text-zinc-300 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent" style={{ backgroundColor: codeBg }}>
<NodeViewContent className="block min-w-full !p-4 !bg-transparent !whitespace-pre outline-none !font-mono !text-sm !leading-6" /> <NodeViewContent className="block min-w-full !p-4 !bg-transparent !whitespace-pre outline-none !font-mono !text-sm !leading-6" />
</pre> </pre>
</div> </div>
+57 -51
View File
@@ -1,28 +1,40 @@
"use client"; "use client";
import React, { Component } from "react"; import React, { Component } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Editor, Range } from "@tiptap/core";
export class CommandList extends Component<{ export type SlashItemGroup = "基础" | "列表" | "插入" | "样式";
items: any[];
command: any; export interface CommandItem {
editor: any; title: string;
range: any; 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; selectedIndex: number;
}> { };
constructor(props: any) {
export class CommandList extends Component<CommandListProps, CommandListState> {
constructor(props: CommandListProps) {
super(props); super(props);
this.state = { this.state = {
selectedIndex: 0, selectedIndex: 0,
}; };
} }
componentDidUpdate(prevProps: any) { componentDidUpdate(prevProps: CommandListProps) {
if (this.props.items !== prevProps.items) { if (this.props.items !== prevProps.items) {
this.setState({ this.setState({ selectedIndex: 0 });
selectedIndex: 0,
});
} }
} }
@@ -46,17 +58,19 @@ export class CommandList extends Component<{
} }
upHandler() { upHandler() {
this.setState({ const total = this.getFlattenedItems().length;
selectedIndex: if (total === 0) return;
(this.state.selectedIndex + this.props.items.length - 1) % this.setState((prev) => ({
this.props.items.length, selectedIndex: (prev.selectedIndex + total - 1) % total,
}); }));
} }
downHandler() { downHandler() {
this.setState({ const total = this.getFlattenedItems().length;
selectedIndex: (this.state.selectedIndex + 1) % this.props.items.length, if (total === 0) return;
}); this.setState((prev) => ({
selectedIndex: (prev.selectedIndex + 1) % total,
}));
} }
enterHandler() { enterHandler() {
@@ -77,32 +91,24 @@ export class CommandList extends Component<{
return Object.values(grouped).flat(); return Object.values(grouped).flat();
} }
groupItems(items: any[]) { groupItems(items: CommandItem[]) {
const groups: Record<string, any[]> = { const groups: Record<SlashItemGroup, CommandItem[]> = {
"基础": [], : [],
"排版": [], : [],
"插入": [], : [],
"高级": [], : [],
"列表": [],
"样式": []
}; };
items.forEach(item => { items.forEach((item) => {
if (item.group && groups[item.group]) { groups[item.group].push(item);
groups[item.group].push(item);
} else {
groups["基础"].push(item);
}
}); });
// Remove empty groups return (Object.keys(groups) as SlashItemGroup[])
return Object.keys(groups) .filter((key) => groups[key].length > 0)
.filter(key => groups[key].length > 0)
.reduce((obj, key) => { .reduce((obj, key) => {
// @ts-ignore
obj[key] = groups[key]; obj[key] = groups[key];
return obj; return obj;
}, {} as Record<string, any[]>); }, {} as Record<SlashItemGroup, CommandItem[]>);
} }
render() { render() {
@@ -114,13 +120,13 @@ export class CommandList extends Component<{
return ( return (
<div className="z-50 h-auto max-h-[500px] w-[800px] overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950/95 shadow-2xl backdrop-blur-lg animate-in fade-in zoom-in-95 duration-200"> <div className="z-50 h-auto max-h-[500px] w-[800px] overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950/95 shadow-2xl backdrop-blur-lg animate-in fade-in zoom-in-95 duration-200">
<div className="columns-3 gap-2 p-3 space-y-4"> <div className="columns-3 gap-2 p-3 space-y-4">
{Object.entries(grouped).map(([groupName, groupItems]: [string, any[]]) => ( {Object.entries(grouped).map(([groupName, groupItems]) => (
<div key={groupName} className="break-inside-avoid mb-4"> <div key={groupName} className="break-inside-avoid mb-4">
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500 select-none mb-1"> <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500 select-none mb-1">
{groupName} {groupName}
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{groupItems.map((item: any, index: number) => { {groupItems.map((item, index) => {
const currentGlobalIndex = globalIndex++; const currentGlobalIndex = globalIndex++;
const isSelected = selectedIndex === currentGlobalIndex; const isSelected = selectedIndex === currentGlobalIndex;
return ( return (
@@ -131,19 +137,19 @@ export class CommandList extends Component<{
? "bg-zinc-800 text-zinc-100" ? "bg-zinc-800 text-zinc-100"
: "text-zinc-400 hover:bg-zinc-900/50 hover:text-zinc-200" : "text-zinc-400 hover:bg-zinc-900/50 hover:text-zinc-200"
)} )}
key={index} key={`${item.title}-${index}`}
onClick={() => this.selectItem(currentGlobalIndex)} onClick={() => this.selectItem(currentGlobalIndex)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={cn( <div
"flex h-6 w-6 items-center justify-center rounded bg-zinc-900 shadow-sm border border-zinc-800", className={cn(
isSelected ? "border-zinc-700 bg-zinc-700" : "group-hover:border-zinc-700" "flex h-6 w-6 items-center justify-center rounded bg-zinc-900 shadow-sm border border-zinc-800",
)}> isSelected ? "border-zinc-700 bg-zinc-700" : "group-hover:border-zinc-700"
)}
>
{item.icon} {item.icon}
</div> </div>
<span className="text-xs font-medium"> <span className="text-xs font-medium">{item.title}</span>
{item.title}
</span>
</div> </div>
{item.shortcut && ( {item.shortcut && (
<span className="text-[10px] font-mono text-zinc-600 group-hover:text-zinc-500 ml-4"> <span className="text-[10px] font-mono text-zinc-600 group-hover:text-zinc-500 ml-4">
@@ -159,7 +165,7 @@ export class CommandList extends Component<{
</div> </div>
{items.length === 0 && ( {items.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 text-zinc-500 w-full"> <div className="flex flex-col items-center justify-center py-6 text-zinc-500 w-full">
<p className="text-xs">No matching commands</p> <p className="text-xs"></p>
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -1,7 +1,7 @@
import { Mark, mergeAttributes } from '@tiptap/core'; import { Mark, mergeAttributes } from '@tiptap/core';
export interface AIMarkOptions { export interface AIMarkOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, unknown>;
} }
export const AIMark = Mark.create<AIMarkOptions>({ export const AIMark = Mark.create<AIMarkOptions>({
+1 -1
View File
@@ -3,7 +3,7 @@ import { ReactNodeViewRenderer } from '@tiptap/react'
import { CalloutComponent } from './callout-component' import { CalloutComponent } from './callout-component'
export interface CalloutOptions { export interface CalloutOptions {
HTMLAttributes: Record<string, any> HTMLAttributes: Record<string, unknown>
} }
declare module '@tiptap/core' { declare module '@tiptap/core' {
+164 -105
View File
@@ -1,15 +1,36 @@
import { Extension } from "@tiptap/core"; import { Extension, type Editor, type Range } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion"; import Suggestion, { type SuggestionProps } from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react"; import { ReactRenderer } from "@tiptap/react";
import tippy, { Instance as TippyInstance } from "tippy.js"; import tippy, { type Instance as TippyInstance } from "tippy.js";
import { CommandList } from "./command-list"; import { CommandList, type CommandItem } from "./command-list";
import { import {
Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Heading1,
List, ListOrdered, Quote, Heading2,
Code, CheckSquare, Minus, Info, Type, Heading3,
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Highlighter Heading4,
Heading5,
Heading6,
List,
ListOrdered,
Quote,
Code,
CheckSquare,
Minus,
Info,
Type,
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
Highlighter,
} from "lucide-react"; } 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({ export const SlashCommand = Extension.create({
name: "slashCommand", name: "slashCommand",
@@ -17,7 +38,7 @@ export const SlashCommand = Extension.create({
return { return {
suggestion: { suggestion: {
char: "/", char: "/",
command: ({ editor, range, props }: any) => { command: ({ editor, range, props }: { editor: Editor; range: Range; props: CommandItem }) => {
props.command({ editor, range }); props.command({ editor, range });
}, },
}, },
@@ -34,215 +55,246 @@ export const SlashCommand = Extension.create({
}, },
}); });
export const getSuggestionItems = ({ query }: { query: string }) => { export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] => {
const items = [ const items: CommandItem[] = [
// --- 基础块 ---
{ {
title: "一级标题", title: "一级标题",
description: "Big section heading", description: "大标题",
group: "基础", group: "基础",
icon: <Heading1 size={16} />, icon: <Heading1 size={16} />,
shortcut: "Ctrl+Alt+1", shortcut: "Ctrl+Alt+1",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
});
}, },
}, },
{ {
title: "二级标题", title: "二级标题",
description: "Medium section heading", description: "中标题",
group: "基础", group: "基础",
icon: <Heading2 size={16} />, icon: <Heading2 size={16} />,
shortcut: "Ctrl+Alt+2", shortcut: "Ctrl+Alt+2",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
});
}, },
}, },
{ {
title: "三级标题", title: "三级标题",
description: "Small section heading", description: "小标题",
group: "基础", group: "基础",
icon: <Heading3 size={16} />, icon: <Heading3 size={16} />,
shortcut: "Ctrl+Alt+3", shortcut: "Ctrl+Alt+3",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
});
}, },
}, },
{ {
title: "普通文本", title: "正文",
description: "Just start writing with plain text", description: "普通段落",
group: "基础", group: "基础",
icon: <Type size={16} />, icon: <Type size={16} />,
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setParagraph().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setParagraph().run();
});
}, },
}, },
// --- 列表 & 引用 ---
{ {
title: "无序列表", title: "无序列表",
description: "Create a simple bullet list", description: "项目符号列表",
group: "列表", group: "列表",
icon: <List size={16} />, icon: <List size={16} />,
shortcut: "-", shortcut: "-",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
});
}, },
}, },
{ {
title: "有序列表", title: "有序列表",
description: "Create a numbered list", description: "编号列表",
group: "列表", group: "列表",
icon: <ListOrdered size={16} />, icon: <ListOrdered size={16} />,
shortcut: "1.", shortcut: "1.",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
});
}, },
}, },
{ {
title: "任务列表", title: "任务列表",
description: "Track tasks", description: "待办事项",
group: "列表", group: "列表",
icon: <CheckSquare size={16} />, icon: <CheckSquare size={16} />,
shortcut: "Ctrl+L", shortcut: "Ctrl+L",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
});
}, },
}, },
{ {
title: "引", title: "引",
description: "Capture a quote", description: "引用块",
group: "列表", group: "列表",
icon: <Quote size={16} />, icon: <Quote size={16} />,
shortcut: ">", shortcut: ">",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setBlockquote().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setBlockquote().run();
});
}, },
}, },
// --- 插入 ---
{ {
title: "代码块", title: "代码块",
description: "Capture a code snippet", description: "插入代码段",
group: "插入", group: "插入",
icon: <Code size={16} />, icon: <Code size={16} />,
shortcut: "```", shortcut: "```",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setCodeBlock().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setCodeBlock().run();
});
}, },
}, },
{ {
title: "分割线", title: "分割线",
description: "Horizontal rule", description: "横线",
group: "插入", group: "插入",
icon: <Minus size={16} />, icon: <Minus size={16} />,
shortcut: "---", shortcut: "---",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
});
}, },
}, },
{ {
title: "高亮块 (Callout)", title: "高亮块Callout",
description: "Callout box", description: "提示框",
group: "插入", group: "插入",
icon: <Info size={16} />, icon: <Info size={16} />,
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setCallout().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setCallout().run();
});
}, },
}, },
{ {
title: "表格", title: "表格",
description: "Insert a 3x3 table", description: "插入 3x3 表格",
group: "插入", group: "插入",
icon: <div className="text-xs font-bold border rounded px-1">T</div>, // Use generic icon if lucide missing or import specific icon: <div className="text-xs font-bold border rounded px-1">T</div>,
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
});
}, },
}, },
{ {
title: "图片", title: "图片",
description: "Insert image from URL", description: "通过 URL 插入图片",
group: "插入", group: "插入",
icon: <div className="text-xs font-bold border rounded px-1">I</div>, icon: <div className="text-xs font-bold border rounded px-1">I</div>,
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
const url = window.prompt('Image URL:'); const url = window.prompt("图片 URL");
if (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", title: "YouTube",
description: "Embed YouTube video", description: "嵌入视频",
group: "插入", group: "插入",
icon: <div className="text-xs font-bold border rounded px-1">Y</div>, icon: <div className="text-xs font-bold border rounded px-1">Y</div>,
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
const url = window.prompt('YouTube URL:'); const url = window.prompt("YouTube URL");
if (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: "粗体", title: "粗体",
description: "Bold text", description: "加粗",
group: "样式", group: "样式",
icon: <Bold size={16} />, icon: <Bold size={16} />,
shortcut: "Ctrl+B", shortcut: "Ctrl+B",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBold().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBold().run();
});
}, },
}, },
{ {
title: "斜体", title: "斜体",
description: "Italic text", description: "倾斜",
group: "样式", group: "样式",
icon: <Italic size={16} />, icon: <Italic size={16} />,
shortcut: "Ctrl+I", shortcut: "Ctrl+I",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleItalic().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleItalic().run();
});
}, },
}, },
{ {
title: "下划线", title: "下划线",
description: "Underline text", description: "下划线",
group: "样式", group: "样式",
icon: <UnderlineIcon size={16} />, icon: <UnderlineIcon size={16} />,
shortcut: "Ctrl+U", shortcut: "Ctrl+U",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleUnderline().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleUnderline().run();
});
}, },
}, },
{ {
title: "删除线", title: "删除线",
description: "Strike text", description: "中划线",
group: "样式", group: "样式",
icon: <Strikethrough size={16} />, icon: <Strikethrough size={16} />,
shortcut: "Ctrl+Shift+S", shortcut: "Ctrl+Shift+S",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleStrike().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleStrike().run();
});
}, },
}, },
{ {
title: "高亮 (Mark)", title: "高亮标记",
description: "Highlight text", description: "文本高亮",
group: "样式", group: "样式",
icon: <Highlighter size={16} />, icon: <Highlighter size={16} />,
shortcut: "Alt+D", shortcut: "Alt+D",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleHighlight().run(); run(editor, range, ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleHighlight().run();
});
}, },
}, },
// --- 排版补充 ---
{ {
title: "四级标题", title: "四级标题",
group: "基础", group: "基础",
icon: <Heading4 size={16} />, icon: <Heading4 size={16} />,
shortcut: "Ctrl+Alt+4", shortcut: "Ctrl+Alt+4",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run(); 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: "基础", group: "基础",
icon: <Heading5 size={16} />, icon: <Heading5 size={16} />,
shortcut: "Ctrl+Alt+5", shortcut: "Ctrl+Alt+5",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run(); 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: "基础", group: "基础",
icon: <Heading6 size={16} />, icon: <Heading6 size={16} />,
shortcut: "Ctrl+Alt+6", shortcut: "Ctrl+Alt+6",
command: ({ editor, range }: any) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run(); 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())); return items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase()));
}; };
type CommandListHandle = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
};
export const renderSuggestionItems = () => { export const renderSuggestionItems = () => {
let component: ReactRenderer; let component: ReactRenderer<CommandListHandle>;
let popup: TippyInstance[]; let popup: TippyInstance | null = null;
return { return {
onStart: (props: any) => { onStart: (props: SuggestionProps<CommandItem>) => {
component = new ReactRenderer(CommandList, { component = new ReactRenderer(CommandList, {
props, props,
editor: props.editor, editor: props.editor,
@@ -283,9 +343,8 @@ export const renderSuggestionItems = () => {
return; return;
} }
// @ts-ignore popup = tippy(document.body, {
popup = tippy("body", { getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, appendTo: () => document.body,
content: component.element, content: component.element,
showOnCreate: true, showOnCreate: true,
@@ -295,30 +354,30 @@ export const renderSuggestionItems = () => {
}); });
}, },
onUpdate: (props: any) => { onUpdate: (props: SuggestionProps<CommandItem>) => {
component.updateProps(props); component.updateProps(props);
if (!props.clientRect) { if (!props.clientRect || !popup) {
return; return;
} }
popup[0].setProps({ popup.setProps({
getReferenceClientRect: props.clientRect, getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
}); });
}, },
onKeyDown: (props: any) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
popup[0].hide(); popup?.hide();
return true; return true;
} }
// @ts-ignore return component.ref?.onKeyDown(props) ?? false;
return component.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
popup?.[0]?.destroy(); popup?.destroy();
popup = null;
component.destroy(); component.destroy();
}, },
}; };
+64 -54
View File
@@ -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 { useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export";
import { useRouter, usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { useImport } from "@/components/import-context";
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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 { interface TreeViewProps {
pages: Page[]; pages: Page[];
@@ -17,33 +30,32 @@ interface TreeViewProps {
level?: number; level?: number;
} }
function TreeNode({ node, pages, level, expanded, toggleExpand }: { function TreeNode({
node: Page, node,
pages: Page[], pages,
level: number, level,
expanded: Record<string, boolean>, expanded,
toggleExpand: (id: string) => void toggleExpand,
}: {
node: Page;
pages: Page[];
level: number;
expanded: Record<string, boolean>;
toggleExpand: (id: string) => void;
}) { }) {
const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore(); const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { triggerImport } = useImport(); const { triggerImport } = useImport();
const confirm = useConfirm();
const isFolder = node.type === 'folder'; const isFolder = node.type === "folder";
const hasChildren = pages.some(p => p.parentId === node.id); const hasChildren = pages.some((p) => p.parentId === node.id);
const isExpanded = expanded[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, 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 = { 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", "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 activePageId === node.id
? "bg-accent text-accent-foreground font-medium" ? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground", : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
// Use simple hover effect for "drop over" visual or rely on drag overlay
)} )}
onClick={() => { onClick={() => {
setActivePageId(node.id); setActivePageId(node.id);
if (pathname !== '/') router.push('/'); if (pathname !== "/") router.push("/");
}} }}
> >
<div className="flex items-center gap-1.5 flex-1 min-w-0"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
<button <button
onPointerDown={(e) => e.stopPropagation()} // Prevent drag start on expand button onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleExpand(node.id); toggleExpand(node.id);
@@ -102,7 +113,7 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button <button
onPointerDown={(e) => e.stopPropagation()} // Prevent drag onPointerDown={(e) => e.stopPropagation()}
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 p-1 hover:bg-muted-foreground/20 rounded text-muted-foreground transition-opacity" className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 p-1 hover:bg-muted-foreground/20 rounded text-muted-foreground transition-opacity"
> >
<MoreHorizontal size={14} /> <MoreHorizontal size={14} />
@@ -114,13 +125,10 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none" className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const targetParentId = node.type === 'folder' ? node.id : node.parentId; const targetParentId = node.type === "folder" ? node.id : node.parentId;
// If creating sibling (node.type !== 'folder'), insert after current node const order = node.type === "folder" ? undefined : (node.order || 0) + 1;
// Note: Order might be float/int. We just increment for now. addPage(targetParentId, "file", undefined, order);
// Ideally we'd find the mid-point, but simpler logic: just +1 and let sort handle basic "after" if (node.type === "folder") toggleExpand(node.id);
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
addPage(targetParentId, 'file', undefined, order);
if (node.type === 'folder') toggleExpand(node.id);
}} }}
> >
<FilePlus size={14} /> <FilePlus size={14} />
@@ -130,10 +138,10 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none" className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const targetParentId = node.type === 'folder' ? node.id : node.parentId; const targetParentId = node.type === "folder" ? node.id : node.parentId;
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1; const order = node.type === "folder" ? undefined : (node.order || 0) + 1;
addPage(targetParentId, 'folder', undefined, order); addPage(targetParentId, "folder", undefined, order);
if (node.type === 'folder') toggleExpand(node.id); if (node.type === "folder") toggleExpand(node.id);
}} }}
> >
<FolderPlus size={14} /> <FolderPlus size={14} />
@@ -143,9 +151,9 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none" className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const targetParentId = node.type === 'folder' ? node.id : node.parentId; const targetParentId = node.type === "folder" ? node.id : node.parentId;
triggerImport(targetParentId); triggerImport(targetParentId);
if (node.type === 'folder') toggleExpand(node.id); if (node.type === "folder") toggleExpand(node.id);
}} }}
> >
<Upload size={14} /> <Upload size={14} />
@@ -155,7 +163,7 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none" className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (node.type === 'folder') { if (node.type === "folder") {
exportFolderAsZip(node.id, pages, node.title); exportFolderAsZip(node.id, pages, node.title);
} else { } else {
exportPageAsMarkdown(node); exportPageAsMarkdown(node);
@@ -168,9 +176,16 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
<DropdownMenu.Separator className="h-px bg-muted my-1" /> <DropdownMenu.Separator className="h-px bg-muted my-1" />
<DropdownMenu.Item <DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-red-50 text-red-600 cursor-pointer outline-none" className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-red-50 text-red-600 cursor-pointer outline-none"
onClick={(e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
if (confirm("确定要删除吗?此操作无法撤销。")) { const ok = await confirm({
title: "确认删除",
description: "此操作无法撤销。",
confirmText: "删除",
cancelText: "取消",
tone: "danger",
});
if (ok) {
deletePage(node.id); deletePage(node.id);
} }
}} }}
@@ -191,27 +206,22 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
} }
export function TreeView({ pages, parentId, level = 0 }: TreeViewProps) { export function TreeView({ pages, parentId, level = 0 }: TreeViewProps) {
// Sort nodes by order field
const nodes = pages const nodes = pages
.filter(p => p.parentId === parentId) .filter((p) => p.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0)); .sort((a, b) => (a.order || 0) - (b.order || 0));
// Simple state for expansion
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = (id: string) => { const toggleExpand = (id: string) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] })); setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}; };
if (nodes.length === 0) return null; if (nodes.length === 0) return null;
return ( return (
<SortableContext <SortableContext items={nodes.map((n) => n.id)} strategy={verticalListSortingStrategy}>
items={nodes.map(n => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{nodes.map(node => ( {nodes.map((node) => (
<TreeNode <TreeNode
key={node.id} key={node.id}
node={node} node={node}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSessionCookieName, verifySessionToken } from "@/lib/session";
export async function requireApiAuth(): Promise<NextResponse | null> {
const cookieStore = await cookies();
const authCookie = cookieStore.get(getSessionCookieName());
const isAuthenticated = authCookie ? await verifySessionToken(authCookie.value) : false;
if (!isAuthenticated) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
+2 -3
View File
@@ -18,10 +18,9 @@ turndownService.use(gfm);
// Configure Turndown to handle some common elements better if needed // Configure Turndown to handle some common elements better if needed
turndownService.addRule('strikethrough', { turndownService.addRule('strikethrough', {
// eslint-disable-next-line @typescript-eslint/no-explicit-any filter: ['del', 's'],
filter: ['del', 's', 'strike'] as any,
replacement: function (content: string) { replacement: function (content: string) {
return '~' + content + '~'; return '~~' + content + '~~';
} }
}); });
+130
View File
@@ -0,0 +1,130 @@
const ALLOWED_TAGS = new Set([
"a",
"b",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
]);
const DROP_CONTENT_TAGS = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"]);
const GLOBAL_ALLOWED_ATTRS = new Set(["class"]);
const TAG_ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(["href", "title", "target", "rel"]),
code: new Set(["class"]),
span: new Set(["class"]),
};
function isSafeUrl(value: string): boolean {
const normalized = value.trim().toLowerCase();
if (!normalized) return false;
if (
normalized.startsWith("http://") ||
normalized.startsWith("https://") ||
normalized.startsWith("mailto:") ||
normalized.startsWith("tel:") ||
normalized.startsWith("/") ||
normalized.startsWith("#")
) {
return true;
}
return false;
}
function sanitizeAttributes(element: Element): void {
const tagName = element.tagName.toLowerCase();
const allowedForTag = TAG_ALLOWED_ATTRS[tagName] || new Set<string>();
const attrs = Array.from(element.attributes);
for (const attr of attrs) {
const name = attr.name.toLowerCase();
const value = attr.value;
const allowed = GLOBAL_ALLOWED_ATTRS.has(name) || allowedForTag.has(name);
if (!allowed) {
element.removeAttribute(attr.name);
continue;
}
if ((name === "href" || name === "src") && !isSafeUrl(value)) {
element.removeAttribute(attr.name);
}
}
if (tagName === "a") {
if (element.getAttribute("target") === "_blank") {
element.setAttribute("rel", "noopener noreferrer nofollow");
} else {
element.removeAttribute("target");
}
}
}
function sanitizeNode(node: Node): void {
if (node.nodeType === Node.TEXT_NODE) return;
if (node.nodeType !== Node.ELEMENT_NODE) {
node.parentNode?.removeChild(node);
return;
}
const element = node as Element;
const tagName = element.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tagName)) {
if (DROP_CONTENT_TAGS.has(tagName)) {
element.remove();
return;
}
const parent = element.parentNode;
if (!parent) return;
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
return;
}
sanitizeAttributes(element);
const children = Array.from(element.childNodes);
for (const child of children) {
sanitizeNode(child);
}
}
export function sanitizeHtml(input: string): string {
if (typeof window === "undefined") return input;
const parser = new DOMParser();
const doc = parser.parseFromString(input, "text/html");
const nodes = Array.from(doc.body.childNodes);
for (const node of nodes) {
sanitizeNode(node);
}
return doc.body.innerHTML;
}
+37 -17
View File
@@ -1,4 +1,4 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
export interface AIPrompt { export interface AIPrompt {
@@ -12,38 +12,39 @@ export const defaultPrompts: AIPrompt[] = [
{ {
id: "improve", id: "improve",
label: "润色内容", label: "润色内容",
description: "提升语表达质量", description: "提升语表达质量",
systemPrompt: "你是专业的写作润色助手。请优化以下文本,使其更流畅、专业,具有吸引力,同时保持原意。请直接输出优化后的文本,不要包含任何解释或'优化后'等前缀。", systemPrompt:
"你是专业的写作润色助手。请优化用户提供的文本,使其更清晰、流畅、专业,并保持原意。请直接输出优化后的文本。",
}, },
{ {
id: "complete", id: "complete",
label: "续写内容", label: "续写内容",
description: "基于上下文自续写", description: "基于上下文自续写",
systemPrompt: "你是富有创意的写作助手。请根据以下上下文逻辑,自然地续写一段内容,风格与前文保持一致。", systemPrompt:
"你是富有创意的写作助手。请根据用户提供的上下文自然续写,保证语气和风格一致。",
}, },
{ {
id: "summarize", id: "summarize",
label: "生成摘要", label: "生成摘要",
description: "提取核心观点", description: "提取核心观点",
systemPrompt: "你是专业的文档总结助手。请为以下文本生成一份简明扼要的摘要,提取核心观点,使用中文回答。", systemPrompt:
"你是专业的文档总结助手。请对用户文本进行简明摘要,提取关键要点,使用中文回答。",
}, },
]; ];
interface SettingsState { interface SettingsState {
fontFamily: string; fontFamily: string;
fontSize: number; // in pixels (e.g. 16) fontSize: number;
lineHeight: number; // e.g. 1.5 lineHeight: number;
tableLineHeight: number; // e.g. 1.2 tableLineHeight: number;
timezone: string; timezone: string;
// AI Config
aiConfig: { aiConfig: {
apiKey: string; apiKey: string;
baseURL: string; baseURL: string;
model: string; model: string;
}; };
// Custom Prompts
prompts: AIPrompt[]; prompts: AIPrompt[];
setFontFamily: (font: string) => void; setFontFamily: (font: string) => void;
@@ -53,7 +54,6 @@ interface SettingsState {
setTimezone: (timezone: string) => void; setTimezone: (timezone: string) => void;
setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void; setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void;
// Prompt Actions
setPrompts: (prompts: AIPrompt[]) => void; setPrompts: (prompts: AIPrompt[]) => void;
addPrompt: (prompt: AIPrompt) => void; addPrompt: (prompt: AIPrompt) => void;
updatePrompt: (id: string, prompt: Partial<AIPrompt>) => void; updatePrompt: (id: string, prompt: Partial<AIPrompt>) => void;
@@ -64,7 +64,7 @@ interface SettingsState {
export const useSettingsStore = create<SettingsState>()( export const useSettingsStore = create<SettingsState>()(
persist( persist(
(set) => ({ (set) => ({
fontFamily: "Inter", // Default fontFamily: "Inter",
fontSize: 16, fontSize: 16,
lineHeight: 1.5, lineHeight: 1.5,
tableLineHeight: 1.2, tableLineHeight: 1.2,
@@ -83,18 +83,38 @@ export const useSettingsStore = create<SettingsState>()(
setLineHeight: (height) => set({ lineHeight: height }), setLineHeight: (height) => set({ lineHeight: height }),
setTableLineHeight: (height) => set({ tableLineHeight: height }), setTableLineHeight: (height) => set({ tableLineHeight: height }),
setTimezone: (timezone) => set({ timezone }), setTimezone: (timezone) => set({ timezone }),
setAIConfig: (config) => set((state) => ({ aiConfig: { ...state.aiConfig, ...config } })), setAIConfig: (config) =>
set((state) => ({
aiConfig: {
...state.aiConfig,
...config,
},
})),
setPrompts: (prompts) => set({ prompts }), setPrompts: (prompts) => set({ prompts }),
addPrompt: (prompt) => set((state) => ({ prompts: [...state.prompts, prompt] })), addPrompt: (prompt) => set((state) => ({ prompts: [...state.prompts, prompt] })),
updatePrompt: (id, prompt) => set((state) => ({ updatePrompt: (id, prompt) =>
prompts: state.prompts.map((p) => (p.id === id ? { ...p, ...prompt } : p)), set((state) => ({
})), prompts: state.prompts.map((p) => (p.id === id ? { ...p, ...prompt } : p)),
})),
deletePrompt: (id) => set((state) => ({ prompts: state.prompts.filter((p) => p.id !== id) })), deletePrompt: (id) => set((state) => ({ prompts: state.prompts.filter((p) => p.id !== id) })),
resetPrompts: () => set({ prompts: defaultPrompts }), resetPrompts: () => set({ prompts: defaultPrompts }),
}), }),
{ {
name: "noteai-settings", name: "noteai-settings",
partialize: (state) => ({
fontFamily: state.fontFamily,
fontSize: state.fontSize,
lineHeight: state.lineHeight,
tableLineHeight: state.tableLineHeight,
timezone: state.timezone,
aiConfig: {
baseURL: state.aiConfig.baseURL,
model: state.aiConfig.model,
apiKey: "",
},
prompts: state.prompts,
}),
} }
) )
); );
+35 -23
View File
@@ -1,4 +1,4 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
export interface Page { export interface Page {
@@ -7,7 +7,7 @@ export interface Page {
content: string; content: string;
parentId: string | null; parentId: string | null;
type: "file" | "folder"; type: "file" | "folder";
children?: Page[]; // For tree view structures (virtual field) children?: Page[];
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
icon?: string | null; icon?: string | null;
@@ -21,14 +21,18 @@ interface EditorState {
activePageId: string | null; activePageId: string | null;
isLoading: boolean; isLoading: boolean;
// Actions
fetchPages: () => Promise<void>; fetchPages: () => Promise<void>;
setActivePageId: (id: string | null) => void; setActivePageId: (id: string | null) => void;
addPage: (parentId?: string | null, type?: "file" | "folder", initialData?: { title: string, content: string }, order?: number) => Promise<void>; addPage: (
parentId?: string | null,
type?: "file" | "folder",
initialData?: { title: string; content: string },
order?: number
) => Promise<void>;
updatePage: (id: string, data: Partial<Page>) => Promise<void>; updatePage: (id: string, data: Partial<Page>) => Promise<void>;
deletePage: (id: string) => Promise<void>; deletePage: (id: string) => Promise<void>;
movePage: (id: string, parentId: string | null) => Promise<void>; movePage: (id: string, parentId: string | null) => Promise<void>;
reorderPages: (data: { id: string, order: number }[]) => Promise<void>; reorderPages: (data: { id: string; order: number }[]) => Promise<void>;
} }
export const useEditorStore = create<EditorState>()( export const useEditorStore = create<EditorState>()(
@@ -45,8 +49,6 @@ export const useEditorStore = create<EditorState>()(
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
set({ pages: data }); set({ pages: data });
// If no active page, maybe select the first one? Or leave null.
// Persistence will handle restoring activePageId if it exists.
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch pages", e); console.error("Failed to fetch pages", e);
@@ -68,14 +70,14 @@ export const useEditorStore = create<EditorState>()(
tags: [], tags: [],
parentId, parentId,
type, type,
order order,
}), }),
}); });
if (res.ok) { if (res.ok) {
const newPage = await res.json(); const newPage = await res.json();
set((state) => ({ set((state) => ({
pages: [newPage, ...state.pages], pages: [newPage, ...state.pages],
activePageId: newPage.id // Auto select new page activePageId: newPage.id,
})); }));
} }
} catch (e) { } catch (e) {
@@ -84,67 +86,77 @@ export const useEditorStore = create<EditorState>()(
}, },
updatePage: async (id, data) => { updatePage: async (id, data) => {
// Optimistic update const prevPages = get().pages;
set((state) => ({ set((state) => ({
pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)), pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)),
})); }));
// Debounce logic could be added here, but for now direct call
try { try {
await fetch(`/api/pages/${id}`, { const res = await fetch(`/api/pages/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!res.ok) {
throw new Error(`Update failed: ${res.status}`);
}
} catch (e) { } catch (e) {
console.error("Failed to update page", e); console.error("Failed to update page", e);
set({ pages: prevPages });
} }
}, },
movePage: async (id, parentId) => { movePage: async (id, parentId) => {
// Reuse updatePage logic
await get().updatePage(id, { parentId }); await get().updatePage(id, { parentId });
}, },
deletePage: async (id) => { deletePage: async (id) => {
// Optimistic delete const prevPages = get().pages;
const currentActive = get().activePageId; const prevActive = get().activePageId;
set((state) => ({ set((state) => ({
pages: state.pages.filter((p) => p.id !== id), pages: state.pages.filter((p) => p.id !== id),
activePageId: currentActive === id ? null : currentActive activePageId: prevActive === id ? null : prevActive,
})); }));
try { try {
await fetch(`/api/pages/${id}`, { const res = await fetch(`/api/pages/${id}`, {
method: "DELETE", method: "DELETE",
}); });
if (!res.ok) {
throw new Error(`Delete failed: ${res.status}`);
}
} catch (e) { } catch (e) {
console.error("Failed to delete page", e); console.error("Failed to delete page", e);
// Rollback could be added here set({ pages: prevPages, activePageId: prevActive });
} }
}, },
reorderPages: async (updates) => { reorderPages: async (updates) => {
// Optimistic update const prevPages = get().pages;
set((state) => { set((state) => {
const newPages = [...state.pages]; const newPages = [...state.pages];
updates.forEach(({ id, order }) => { updates.forEach(({ id, order }) => {
const page = newPages.find(p => p.id === id); const page = newPages.find((p) => p.id === id);
if (page) page.order = order; if (page) page.order = order;
}); });
// Re-sort locally? Or just trust UI to sort based on updated order property
// It's safer if 'pages' remains the master list
return { pages: newPages }; return { pages: newPages };
}); });
try { try {
await fetch("/api/pages/reorder", { const res = await fetch("/api/pages/reorder", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }), body: JSON.stringify({ updates }),
}); });
if (!res.ok) {
throw new Error(`Reorder failed: ${res.status}`);
}
} catch (e) { } catch (e) {
console.error("Failed to reorder pages", e); console.error("Failed to reorder pages", e);
set({ pages: prevPages });
} }
}, },
}), }),