使用openai进行了代码review
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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>({
|
||||||
|
|||||||
@@ -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' {
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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 + '~~';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user