首次发布git

This commit is contained in:
2026-02-24 09:53:19 +08:00
commit dd97cf6a2c
71 changed files with 14875 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge"; // Optional: Use edge runtime for lower latency
export async function POST(req: NextRequest) {
try {
const { messages, config } = await req.json();
const { apiKey, baseURL, model } = config || {};
if (!apiKey) {
return NextResponse.json({ error: "Missing API Key" }, { status: 401 });
}
// Clean up baseURL: ensure no trailing slash, add /chat/completions if missing?
// Actually, usually users provide standard base URL "https://api.openai.com/v1"
// We should append /chat/completions.
const url = `${baseURL.replace(/\/$/, "")}/chat/completions`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model || "gpt-3.5-turbo",
messages,
stream: true, // Force streaming
}),
});
if (!res.ok) {
const errorText = await res.text();
return NextResponse.json({ error: `Upstream Error: ${res.statusText}`, details: errorText }, { status: res.status });
}
// Return the stream directly
return new Response(res.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
} catch (error) {
console.error("AI API Error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/auth";
export async function POST(req: Request) {
try {
const { password } = await req.json();
// Ensure settings exist, if not, maybe we should init or fail?
// Ideally init should happen on app startup or manual trigger, but for simplicity:
// If no settings exist, check against "admin" (fallback) but DO NOT create DB entry implicitely here for security,
// unless we strictly define that "admin" is the default.
// Let's assume DB must be populated.
const settings = await prisma.globalSettings.findUnique({
where: { id: "default" },
});
const isValid = settings
? await verifyPassword(password, settings.password)
: password === "admin"; // Fallback only if DB empty
if (isValid) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}
} catch (error) {
return NextResponse.json({ error: "Login failed" }, { status: 500 });
}
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete("auth");
return NextResponse.json({ success: true });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const id = (await params).id;
try {
const page = await prisma.page.findUnique({
where: { id },
});
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
} catch (error) {
return NextResponse.json({ error: 'Error fetching page' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const id = (await params).id;
try {
const body = await request.json();
// Separate update logic for flexibility (e.g. only updating title)
const updateData: any = {};
if (body.title !== undefined) updateData.title = body.title;
if (body.content !== undefined) updateData.content = body.content;
if (body.parentId !== undefined) updateData.parentId = body.parentId;
if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags);
if (body.icon !== undefined) updateData.icon = body.icon;
if (body.isLocked !== undefined) updateData.isLocked = body.isLocked;
const page = await prisma.page.update({
where: { id },
data: updateData,
});
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
} catch (error) {
return NextResponse.json({ error: 'Error updating page' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const id = (await params).id;
try {
await prisma.page.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function PUT(request: Request) {
try {
const body = await request.json();
const { updates } = body;
if (!Array.isArray(updates)) {
return NextResponse.json({ error: 'Invalid updates' }, { status: 400 });
}
// Transaction for batch update
await prisma.$transaction(
updates.map((update: { id: string, order: number }) =>
prisma.page.update({
where: { id: update.id },
data: { order: update.order },
})
)
);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Error reordering pages' }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const pages = await prisma.page.findMany({
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
});
const parsedPages = pages.map(p => ({
...p,
tags: JSON.parse(p.tags || "[]")
}));
return NextResponse.json(parsedPages);
} catch (error) {
return NextResponse.json({ error: 'Error fetching pages' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { title, content, parentId, type } = body;
const page = await prisma.page.create({
data: {
title: title || '无标题',
content: content || '',
tags: JSON.stringify(body.tags || []),
parentId: parentId || null,
type: type || 'file',
order: await (async () => {
if (body.order !== undefined) return body.order;
const lastPage = await prisma.page.findFirst({
where: { parentId: parentId || null },
orderBy: { order: 'desc' },
});
return (lastPage?.order ?? -1) + 1;
})(),
icon: body.icon || null,
isLocked: body.isLocked || false,
},
});
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
} catch (error) {
return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
export async function POST() {
try {
const count = await prisma.globalSettings.count();
if (count > 0) {
return NextResponse.json({ message: "Settings already initialized" }, { status: 200 });
}
// Default password: "admin"
const hashedPassword = await hashPassword("admin");
await prisma.globalSettings.create({
data: {
id: "default",
password: hashedPassword,
},
});
return NextResponse.json({ message: "Initialized default settings" }, { status: 201 });
} catch (error) {
console.error("Init Settings Error:", error);
return NextResponse.json({ error: "Failed to initialize settings" }, { status: 500 });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword } from "@/lib/auth";
export async function PUT(req: Request) {
try {
const { currentPassword, newPassword } = await req.json();
// Cast to any to bypass build error until server restart allows prisma generate to run
const settings = await (prisma as any).globalSettings.findUnique({
where: { id: "default" },
});
if (!settings) {
return NextResponse.json({ error: "Settings not found" }, { status: 404 });
}
const isValid = await verifyPassword(currentPassword, settings.password);
if (!isValid) {
return NextResponse.json({ error: "Current password incorrect" }, { status: 401 });
}
const hashedPassword = await hashPassword(newPassword);
await (prisma as any).globalSettings.update({
where: { id: "default" },
data: { password: hashedPassword },
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: "Failed to change password" }, { status: 500 });
}
}
+194
View File
@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import JSZip from "jszip";
import { marked } from "marked";
// Use a global prisma instance to avoid "too many connections" in dev
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
// Disable body parser strictly (Next.js App Router handles FormData naturally)
// export const config = {
// api: {
// bodyParser: false,
// },
// };
// No need for config in App Router route handlers.
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const buffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
// Map to store directory paths to their new DB IDs
// Data format: "folder/subfolder" -> UUID
const pathIdMap = new Map<string, string>();
// Prepare data for proper insertion order (Folders first, then files?)
// Actually, we need to process by path depth to ensure parents exist.
const entries: Array<{ path: string; isDir: boolean; content?: string }> = [];
// 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) {
// Remove trailing slash for consistency
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
if (cleanPath) {
entries.push({ path: cleanPath, isDir: true });
}
} else {
if (relativePath.endsWith(".md")) {
const content = await zipEntry.async("string");
entries.push({ path: relativePath, isDir: false, content });
}
}
})();
filePromises.push(promise);
});
await Promise.all(filePromises);
// 2. Clear existing Pages (Transaction usually)
// Since sqlite doesn't support nested transactions well in Prisma sometimes,
// we'll just do it sequentially but quickly.
// Ideally: await prisma.$transaction([prisma.page.deleteMany(), ...])
// But logic is complex (recursive id generation), so we delete first.
// WARNING: This is destructive.
await prisma.page.deleteMany();
// 3. Sort entries by path depth (number of slashes)
entries.sort((a, b) => {
const depthA = a.path.split('/').length;
const depthB = b.path.split('/').length;
return depthA - depthB;
});
// Helper to get or create parent folder
const ensureParent = async (entryPath: string): Promise<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 });
} catch (e) {
console.error("Restore failed:", e);
return NextResponse.json({ error: "Restore failed: " + String(e) }, { status: 500 });
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+220
View File
@@ -0,0 +1,220 @@
@import 'highlight.js/styles/atom-one-dark.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 93%;
/* Main content: 93% gray */
--foreground: 240 5% 20%;
--card: 0 0% 100%;
--card-foreground: 240 5% 15%;
--popover: 0 0% 100%;
--popover-foreground: 240 5% 15%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 10% 90%;
/* Sidebar: 90% gray */
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 96%;
--muted-foreground: 240 3.8% 46%;
--accent: 240 4.8% 96%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
}
.dark {
/* Notion-like Dark Mode (Inverted Hierarchy) - Deepened */
--background: 0 0% 9%;
/* Main Content: Deep Dark (#171717) */
--foreground: 0 0% 92%;
--card: 0 0% 9%;
/* Match background */
--card-foreground: 0 0% 92%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 92%;
--primary: 0 0% 92%;
--primary-foreground: 0 0% 10%;
--secondary: 0 0% 13%;
/* Sidebar: Lighter than main (#212121), but deeper than before */
--secondary-foreground: 0 0% 92%;
--muted: 0 0% 13%;
--muted-foreground: 0 0% 65%;
--accent: 0 0% 13%;
--accent-foreground: 0 0% 92%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 92%;
--border: 0 0% 18%;
/* Subtle borders */
--input: 0 0% 18%;
--ring: 0 0% 80%;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Editor Specific Styles for Notion-like feel */
.ProseMirror {
outline: none;
min-height: 300px;
padding-bottom: 50px;
font-size: 1.05rem;
line-height: var(--editor-line-height, 1.5);
/* Use CSS variable with fallback */
/* Reduced from 1.75 */
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
pointer-events: none;
height: 0;
}
.ProseMirror blockquote {
border-left: 3px solid hsl(var(--primary));
/* Make border color slightly more visible (primary) */
padding-left: 1rem;
color: hsl(var(--muted-foreground));
font-family: "Georgia", "Cambria", "Times New Roman", serif !important;
/* Force serif font */
font-style: italic;
margin: 1rem 0;
background: hsl(var(--muted) / 0.3);
/* Subtle background */
padding-top: 0.5rem;
padding-bottom: 0.5rem;
border-radius: 0 0.5rem 0.5rem 0;
/* Rounded right corners */
/* Rounded right corners */
}
.ProseMirror code {
background-color: hsl(var(--primary) / 0.1);
color: hsl(var(--foreground));
border-radius: 0.25rem;
padding: 0.2rem 0.4rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85em;
font-weight: 500;
}
.dark .ProseMirror code {
background-color: hsl(var(--primary) / 0.2);
color: hsl(var(--foreground));
}
.ProseMirror code::before,
.ProseMirror code::after {
content: none !important;
}
/* ... existing heading styles ... */
/* Table Styles - Ensure content is compact */
.ProseMirror table p {
margin: 0;
/* Remove paragraph margin inside tables */
line-height: var(--table-line-height, 1.2);
/* Use CSS variable with fallback */
/* Tighter line height for table content */
}
.ProseMirror table {
border-collapse: collapse;
margin: 1em 0;
/* Add some vertical space around the table itself */
overflow: hidden;
table-layout: fixed;
width: 100%;
}
.ProseMirror td,
.ProseMirror th {
border-width: 1px;
border-style: solid;
border-color: hsl(var(--border));
box-sizing: border-box;
min-width: 1em;
padding: 2px 4px;
/* Further reduced padding */
position: relative;
vertical-align: top;
}
.ProseMirror th {
background-color: hsl(var(--muted));
font-weight: bold;
text-align: left;
/* Make header borders visible by using a contrasting color if bg matches border */
border-color: hsl(var(--foreground) / 0.1);
}
/* AI Content Style */
.ai-content {
font-family: "KaiTi", "STKaiti", "楷体", "Georgia", serif;
font-style: italic;
font-weight: normal;
color: hsl(var(--foreground));
/* Ensure text color is standard */
}
/* Specific fix for dark mode if needed, but opacity trick usually works */
.dark .ProseMirror th {
border-color: hsl(var(--background));
/* Use background color for borders in dark mode header to show separation */
}
/* Task List Styles */
ul[data-type="taskList"],
.ProseMirror ul[data-type="taskList"] {
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.ProseMirror li[data-type="taskItem"] div,
.ProseMirror li[data-type="taskItem"] label {
line-height: normal !important;
/* Force tight line height for checkbox items */
margin-top: 2px;
margin-bottom: 2px;
}
/* We are now using a Custom NodeView for li[data-type="taskItem"], so no global overriding needed.
The component handles its own layout. */
/* Override Highlight.js background for a softer look */
.ProseMirror pre {
background: #252529 !important;
/* Softer dark gray (hsl(240 5% 15%)), approx matching foreground */
border-radius: 0.5rem;
}
.hljs {
background: transparent !important;
/* Let pre handle the background */
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "NoteAI - 轻量级个人笔记网站",
description: "基于 Next.js 的 AI 驱动笔记应用",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Lock, Sparkles } from "lucide-react";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [duration, setDuration] = useState("1"); // days
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
let cookieString = "auth=true; path=/";
if (rememberMe) {
const seconds = parseInt(duration) * 24 * 60 * 60;
cookieString += `; max-age=${seconds}`;
}
document.cookie = cookieString;
router.push("/");
} else {
const data = await res.json();
setError(data.error || "登录失败");
}
} catch (err) {
setError("发生错误,请重试");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8 bg-card border p-8 rounded-2xl shadow-xl animate-in fade-in zoom-in-95 duration-500">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4">
<Sparkles size={32} />
</div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">访</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
<input
type="password"
placeholder="访问密码"
value={password}
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"
required
/>
</div>
{error && <p className="text-sm text-destructive font-medium">{error}</p>}
</div>
<div className="flex items-center justify-between text-sm px-1">
<label className="flex items-center gap-2 cursor-pointer text-muted-foreground hover:text-foreground transition-colors select-none">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50"
/>
</label>
{rememberMe && (
<select
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs"
>
<option value="1">1</option>
<option value="7">7</option>
<option value="30">30</option>
</select>
)}
</div>
<button
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"
>
</button>
</form>
<div className="text-center text-xs text-muted-foreground">
NoteAI
</div>
</div>
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
import { Editor } from "@/components/editor";
import { useEditorStore } from "@/lib/store";
import { FileDown, Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock } from "lucide-react";
import { useSettingsStore } from "@/lib/settings-store";
import { exportPageAsMarkdown } from "@/lib/export";
import { useState } from "react";
import { cn, getTagColor } from "@/lib/utils";
import { useSearchStore } from "@/lib/search-store";
import { type Editor as TiptapEditor } from "@tiptap/react";
import { ImportProvider } from "@/components/import-context";
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
export default function Home() {
const { activePageId, pages, updatePage } = useEditorStore();
const { openSearchWithTag } = useSearchStore();
const activePage = pages.find(p => p.id === activePageId);
const [isChatOpen, setIsChatOpen] = useState(false);
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const [isAddingTag, setIsAddingTag] = useState(false);
const [tagInput, setTagInput] = useState("");
return (
<ImportProvider>
<div className="flex h-[100dvh] w-full bg-background overflow-hidden relative">
{/* Mobile: Sidebar List View (Only visible when no page is active) */}
<div className={cn(
activePageId ? "hidden" : "flex-1 h-full md:hidden block"
)}>
<SidebarContent />
</div>
{/* Desktop: Sidebar (Resizable) */}
<ResizableSidebar />
{/* Main Content Area */}
<main className={cn(
"flex-1 h-full overflow-hidden flex flex-col relative z-0",
// Mobile: Hidden when no page active (showing list instead)
!activePageId && "hidden md:flex"
)}>
{activePage ? (
<div className="flex-1 overflow-y-auto scroll-smooth">
<div className="max-w-7xl mx-auto px-4 md:px-16 py-6 min-h-screen content-start">
{/* Mobile Back Button */}
<div className="md:hidden mb-4 flex items-center text-muted-foreground" onClick={() => updatePage(null as any, {} as any)}>
{/* Note: updatePage isn't the right way to clear selection. We need setPageId(null).
But store only exposes updatePage. Let's fix store usage or use a store action if available.
Actually, looking at store.ts, we need `setActivePageId`.
*/}
</div>
<div className="group mb-8 relative">
{/* Mobile Back Button Integration in Header */}
<div className="md:hidden absolute -top-12 left-0 flex items-center gap-1 py-2 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => useEditorStore.getState().setActivePageId(null)}>
<ChevronLeft size={20} />
<span></span>
</div>
{/* Breadcrumb Navigation */}
<div className="flex items-center flex-wrap gap-1 text-sm text-muted-foreground mb-4">
{(() => {
const breadcrumbs = [];
let current: typeof activePage | undefined = activePage;
while (current) {
breadcrumbs.unshift(current);
if (current.parentId) {
current = pages.find(p => p.id === current?.parentId);
} else {
current = undefined;
}
}
return breadcrumbs.map((crumb, index) => (
<div key={crumb.id} className="flex items-center gap-1">
{index > 0 && <ChevronRight size={14} className="opacity-50" />}
<button
onClick={() => useEditorStore.getState().setActivePageId(crumb.id)}
className={cn(
"hover:underline hover:text-foreground transition-colors flex items-center gap-1",
crumb.id === activePage.id && "font-medium text-foreground pointer-events-none"
)}
>
{crumb.icon && <span>{crumb.icon}</span>}
<span>{crumb.title || "无标题"}</span>
</button>
</div>
));
})()}
</div>
<div className="flex items-center gap-2">
{activePage.icon && (
<span className="text-3xl select-none animate-in fade-in zoom-in-75 duration-300">
{activePage.icon}
</span>
)}
<input
value={activePage.title}
onChange={(e) => updatePage(activePage.id, { title: e.target.value })}
placeholder="无标题"
disabled={activePage.isLocked}
className={cn(
"w-full text-3xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/20 text-foreground transition-colors",
activePage.isLocked && "opacity-80 cursor-not-allowed select-none"
)}
/>
</div>
{/* Last Updated Info moved */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1 transition-opacity">
{/* Buttons moved to Editors Toolbar */}
</div>
</div>
{/* Tags Section */}
<div className="flex flex-wrap items-center gap-2 mb-6 animate-in fade-in slide-in-from-top-2 duration-300">
{activePage.tags?.map((tag) => {
const colors = getTagColor(tag);
return (
<span
key={tag}
onClick={() => openSearchWithTag(tag)}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-1 rounded-[3px] text-[11px] font-medium transition-colors border shadow-sm",
colors.bg, colors.text, colors.border
)}>
<Hash size={10} className="opacity-70" />
{tag}
<button
onClick={(e) => {
e.stopPropagation();
const newTags = activePage.tags?.filter(t => t !== tag) || [];
updatePage(activePage.id, { tags: newTags });
}}
className="ml-1 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
>
<X size={10} />
</button>
</span>
);
})}
<div className="relative">
{isAddingTag ? (
<input
autoFocus
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onBlur={() => {
if (tagInput.trim()) {
const newTags = [...(activePage.tags || []), tagInput.trim()];
updatePage(activePage.id, { tags: Array.from(new Set(newTags)) });
}
setTagInput("");
setIsAddingTag(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (tagInput.trim()) {
const newTags = [...(activePage.tags || []), tagInput.trim()];
updatePage(activePage.id, { tags: Array.from(new Set(newTags)) });
}
setTagInput("");
setIsAddingTag(false);
}
if (e.key === 'Escape') {
setTagInput("");
setIsAddingTag(false);
}
}}
className="w-24 px-2 py-0.5 text-xs bg-transparent border border-primary rounded-sm outline-none animate-in fade-in zoom-in-95 duration-200"
placeholder="输入标签..."
/>
) : (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAddingTag(true)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-sm text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-all border border-transparent hover:border-border"
>
<Plus size={12} />
</button>
{/* Icon Picker */}
<div className="relative group/icon-picker">
<button
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-sm text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-all border border-transparent hover:border-border"
>
<Sparkles size={12} />
{activePage.icon ? '更改图标' : '添加图标'}
</button>
<div className="absolute left-0 top-full mt-1 z-50 hidden group-hover/icon-picker:block w-80 p-2 bg-popover border shadow-md rounded-md animate-in fade-in zoom-in-95">
<div className="grid grid-cols-8 gap-1 h-64 overflow-y-auto p-1">
{[
"📄", "📝", "📁", "📂", "📊", "📈", "📉", "📅", "✅", "❌", "📌", "📍", "📎", "🗑️", "⚙️", "🔒",
"✨", "💡", "🔥", "🚀", "🎨", "🎯", "🏆", "💎", "❤️", "👍", "👋", "🎉", "🌟", "⭐", "🌈", "⚡",
"🤖", "🧠", "💻", "⌨️", "📱", "⌚", "📷", "🎥", "🎧", "🎮", "🕹️", "🎲", "🧩", "🎳", "🥋", "🥊",
"🚗", "✈️", "🛸", "🌍", "🪐", "☀️", "🌙", "☁️", "🌧️", "❄️", "🌊", "💧", "🌀",
"🏠", "🏢", "🏥", "🏫", "🏰", "🏯", "⛺", "🏕️", "🌲", "🌳", "🌴", "🌵", "🌷", "🌸", "🌹", "🌻",
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
"🍎", "🍌", "🍇", "🍉", "🍊", "🍋", "🍍", "🥭", "🍓", "🍒", "🍑", "🥝", "🍅", "🥑", "🍆", "🥔",
"🍔", "🍟", "🍕", "🌭", "🥪", "🌮", "🌯", "🥗", "🥘", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟",
"🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🍾", "☕", "🍵", "🥤", "🧃", "🧊", "🥄", "🍴", "🍽️"
].map(icon => (
<button
key={icon}
onClick={(e) => {
e.stopPropagation();
updatePage(activePage.id, { icon });
}}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-sm hover:bg-accent text-lg transition-colors",
activePage.icon === icon && "bg-accent/50 ring-1 ring-primary/20"
)}
>
{icon}
</button>
))}
<button
onClick={(e) => {
e.stopPropagation();
updatePage(activePage.id, { icon: null });
}}
className="w-8 h-8 flex items-center justify-center rounded-sm hover:bg-red-50 text-red-500 hover:text-red-600 transition-colors col-span-1"
title="清除图标"
>
<X size={14} />
</button>
</div>
</div>
</div>
{/* Lock Button */}
<button
onClick={() => updatePage(activePage.id, { isLocked: !activePage.isLocked })}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-sm text-xs font-medium transition-all border border-transparent hover:border-border",
activePage.isLocked
? "text-orange-600 bg-orange-50 hover:bg-orange-100 border-orange-200"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={activePage.isLocked ? "解锁编辑" : "锁定编辑 (防止误触)"}
>
{activePage.isLocked ? <Lock size={12} /> : <Unlock size={12} />}
{activePage.isLocked ? "已锁定" : "锁定"}
</button>
{/* Last Updated Info */}
{activePage.updatedAt && (
<div className="text-xs text-muted-foreground/40 select-none flex items-center gap-1 border-l pl-2 ml-1 h-4">
<span>
{new Date(activePage.updatedAt).toLocaleString('zh-CN', {
timeZone: useSettingsStore.getState().timezone || 'Asia/Shanghai',
hour12: false
})}
</span>
</div>
)}
</div>
)}
</div>
</div>
{/* Editor Area or Folder Placeholder */}
{activePage.type === 'folder' ? (
<div className="flex flex-col items-center justify-center h-[50vh] text-muted-foreground animate-in fade-in duration-500">
{/* ... folder icon ... */}
</div>
) : (
<div className="min-h-[60vh] pb-24">
<Editor
content={activePage.content}
onChange={(content) => updatePage(activePage.id, { content })}
onEditorReady={setEditor}
onToggleAI={() => setIsChatOpen(!isChatOpen)}
onExport={() => exportPageAsMarkdown(activePage)}
editable={!activePage.isLocked}
/>
</div>
)}
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-4 animate-in fade-in zoom-in-95 duration-500">
<div className="w-16 h-16 bg-muted/50 rounded-2xl flex items-center justify-center">
<span className="text-4xl">👋</span>
</div>
<div className="text-center space-y-1">
<h3 className="text-lg font-semibold text-foreground">使 NoteAI</h3>
<p className="text-sm text-muted-foreground/80"></p>
</div>
</div>
)}
</main>
<AIChatPanel editor={editor} isOpen={isChatOpen} onClose={() => setIsChatOpen(false)} />
</div>
</ImportProvider>
);
}
+453
View File
@@ -0,0 +1,453 @@
"use client";
import { useState } from "react";
import { useSettingsStore, defaultPrompts, fontOptions, timezoneOptions } from "@/lib/settings-store";
import { Lock, Type, Save, Sparkles } from "lucide-react";
import { ResizableSidebar } from "@/components/sidebar";
import { PromptManagement } from "@/components/settings/prompt-management";
import { ImportProvider } from "@/components/import-context";
export default function SettingsPage() {
// Appearance
const {
fontSize, setFontSize,
fontFamily, setFontFamily,
lineHeight, setLineHeight,
tableLineHeight, setTableLineHeight,
timezone, setTimezone,
aiConfig, setAIConfig,
prompts, resetPrompts, updatePrompt, deletePrompt, addPrompt
} = useSettingsStore();
// Security
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [msg, setMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setMsg(null);
if (newPassword !== confirmPassword) {
setMsg({ type: 'error', text: "两次输入的新密码不一致" });
return;
}
try {
const res = await fetch("/api/settings/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (res.ok) {
setMsg({ type: 'success', text: "密码修改成功" });
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
const data = await res.json();
setMsg({ type: 'error', text: data.error || "修改失败" });
}
} catch (e) {
setMsg({ type: 'error', text: "系统错误,请重试" });
}
};
return (
<ImportProvider>
<div className="flex h-screen w-full bg-background overflow-hidden relative">
<ResizableSidebar />
<main className="flex-1 h-full flex overflow-hidden bg-background/50">
{/* Settings Navigation Sidebar (Desktop Only) */}
<aside className="w-56 lg:w-64 border-r bg-background/30 hidden md:flex flex-col p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground mt-1"></p>
</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 */}
<div className="flex-1 h-full overflow-y-auto p-4 md:p-12 scroll-smooth">
<div className="max-w-3xl mx-auto space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
{/* Mobile Header (Hidden on Desktop) */}
<div className="md:hidden">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-2"></p>
</div>
{/* Appearance Section */}
<section id="appearance" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<Type size={20} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
</div>
<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
</div>
</div>
</section>
{/* AI Configuration Section */}
<section id="ai-config" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<Sparkles size={20} className="text-primary" />
<h2 className="text-xl font-semibold">AI </h2>
</div>
<div className="space-y-4 max-w-xl">
<div className="space-y-2">
<label className="text-sm font-medium">API Base URL</label>
<input
type="text"
value={aiConfig.baseURL}
onChange={(e) => setAIConfig({ baseURL: e.target.value })}
placeholder="https://api.openai.com/v1"
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
/>
<p className="text-xs text-muted-foreground"> OpenAI </p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">API Key</label>
<input
type="password"
value={aiConfig.apiKey}
onChange={(e) => setAIConfig({ apiKey: e.target.value })}
placeholder="sk-..."
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
/>
</div>
<div className="space-y-2">
<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>
{/* Security Section */}
<section id="security" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<Lock size={20} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
</div>
<form onSubmit={handlePasswordChange} className="space-y-4 max-w-md">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="password"
value={confirmPassword}
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"
required
/>
</div>
{msg && (
<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'}`}>
{msg.text}
</div>
)}
<button
type="submit"
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
<Save size={16} />
</button>
</form>
</section>
{/* Data Management Section */}
<section id="backup" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="text-xl font-semibold">📦 </span>
</div>
<div className="p-4 border rounded-lg bg-orange-50/50 dark:bg-orange-900/10 border-orange-200 dark:border-orange-800/30">
<h3 className="font-medium text-orange-800 dark:text-orange-300 mb-2 flex items-center gap-2">
</h3>
<ul className="list-disc list-inside text-sm text-orange-700 dark:text-orange-400/80 space-y-1">
<li> Markdown (Zip)</li>
<li><b></b></li>
</ul>
</div>
<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 {
await exportAllPagesAsZip(pages);
} catch (e) {
alert("备份失败: " + String(e));
}
}}
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"
>
<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>
</div>
</main >
</div >
</ImportProvider>
);
}