使用openai对UI进行了重构

This commit is contained in:
2026-02-24 11:50:51 +08:00
parent 0c97f02e51
commit 317b9c7c26
15 changed files with 695 additions and 583 deletions
+2
View File
@@ -0,0 +1,2 @@
⚠ Cross origin request detected from 127.0.0.1 to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this.
Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins
+72
View File
@@ -0,0 +1,72 @@
> note-ai@1.0.0105 dev
> next dev -p 3001
▲ Next.js 16.1.1 (Turbopack)
- Local: http://localhost:3001
- Network: http://198.18.0.1:3001
- Environments: .env
✓ Starting...
✓ Ready in 1339ms
GET / 200 in 2.5s (compile: 1896ms, proxy.ts: 145ms, render: 479ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 164ms (compile: 104ms, proxy.ts: 9ms, render: 51ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 32ms (compile: 3ms, proxy.ts: 6ms, render: 22ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 4ms, render: 21ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 5ms, render: 21ms)
prisma:query SELECT `main`.`Page`.`id` FROM `main`.`Page` WHERE (`main`.`Page`.`id` = ? AND 1=1) LIMIT ? OFFSET ?
prisma:query UPDATE `main`.`Page` SET `content` = ?, `updatedAt` = ? WHERE (`main`.`Page`.`id` = ? AND 1=1) RETURNING `id` AS `id`, `title` AS `title`, `content` AS `content`, `tags` AS `tags`, `icon` AS `icon`, `type` AS `type`, `parentId` AS `parentId`, `createdAt` AS `createdAt`, `updatedAt` AS `updatedAt`, `order` AS `order`, `isLocked` AS `isLocked`
PUT /api/pages/d039b54a-ab1c-4341-a14a-70b1b1786118 200 in 898ms (compile: 865ms, proxy.ts: 8ms, render: 25ms)
GET /settings 200 in 208ms (compile: 182ms, proxy.ts: 5ms, render: 21ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 42ms (compile: 7ms, proxy.ts: 14ms, render: 21ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 31ms (compile: 4ms, proxy.ts: 10ms, render: 17ms)
✓ Compiled in 180ms
GET /settings 200 in 116ms (compile: 64ms, proxy.ts: 5ms, render: 47ms)
GET /settings 200 in 38ms (compile: 4ms, proxy.ts: 5ms, render: 29ms)
✓ Compiled in 135ms
GET /settings 200 in 200ms (compile: 61ms, proxy.ts: 4ms, render: 135ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 38ms (compile: 4ms, proxy.ts: 8ms, render: 25ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 23ms (compile: 3ms, proxy.ts: 9ms, render: 11ms)
✓ Compiled in 100ms
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 20ms (compile: 3ms, proxy.ts: 5ms, render: 12ms)
GET /settings 200 in 92ms (compile: 69ms, proxy.ts: 6ms, render: 17ms)
GET / 200 in 129ms (compile: 101ms, proxy.ts: 6ms, render: 22ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 36ms (compile: 10ms, proxy.ts: 13ms, render: 14ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 28ms (compile: 7ms, proxy.ts: 11ms, render: 10ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 25ms (compile: 5ms, proxy.ts: 9ms, render: 11ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 18ms (compile: 3ms, proxy.ts: 7ms, render: 8ms)
✓ Compiled in 455ms
✓ Compiled in 121ms
✓ Compiled in 87ms
GET /settings 200 in 76ms (compile: 57ms, proxy.ts: 5ms, render: 15ms)
prisma:query SELECT 1
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 40ms (compile: 4ms, proxy.ts: 11ms, render: 26ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 38ms (compile: 5ms, proxy.ts: 8ms, render: 25ms)
GET / 200 in 93ms (compile: 73ms, proxy.ts: 4ms, render: 17ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 22ms (compile: 3ms, proxy.ts: 7ms, render: 12ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 20ms (compile: 6ms, proxy.ts: 6ms, render: 8ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 29ms (compile: 1969µs, proxy.ts: 4ms, render: 22ms)
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
GET /api/pages 200 in 28ms (compile: 1741µs, proxy.ts: 4ms, render: 22ms)
BIN
View File
Binary file not shown.
+84
View File
@@ -66,11 +66,95 @@
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
background-image:
radial-gradient(circle at 10% 10%, hsl(var(--primary) / 0.05), transparent 35%),
radial-gradient(circle at 90% 0%, hsl(var(--accent-foreground) / 0.04), transparent 25%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer components {
.ui-card {
@apply rounded-2xl border border-border/70 bg-card/95 shadow-sm backdrop-blur-sm;
}
.ui-input {
@apply w-full rounded-xl border border-input bg-background/75 px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring focus:ring-2 focus:ring-ring/20;
}
.ui-select {
@apply w-full rounded-xl border border-input bg-background/75 px-3 py-2 text-sm outline-none transition-colors focus:border-ring focus:ring-2 focus:ring-ring/20;
}
.ui-btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all active:scale-[0.98];
}
.ui-btn-primary {
@apply ui-btn bg-primary text-primary-foreground hover:opacity-90 shadow-sm;
}
.ui-btn-secondary {
@apply ui-btn bg-secondary text-secondary-foreground hover:bg-secondary/80;
}
.ui-btn-danger {
@apply ui-btn bg-destructive text-destructive-foreground hover:opacity-90;
}
.ui-icon-btn {
@apply inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-muted-foreground transition-colors hover:border-border hover:bg-muted/60 hover:text-foreground;
}
.ui-kbd {
@apply inline-flex h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground;
}
.ui-enter {
animation: ui-enter 220ms ease-out both;
}
.ui-enter-delayed {
animation: ui-enter 300ms ease-out both;
}
.ui-float {
animation: ui-float 4s ease-in-out infinite;
}
}
@keyframes ui-enter {
from {
opacity: 0;
transform: translateY(6px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ui-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-enter,
.ui-enter-delayed,
.ui-float,
.animate-in {
animation: none !important;
transition: none !important;
}
}
/* Editor Specific Styles for Notion-like feel */
.ProseMirror {
outline: none;
+4 -9
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
@@ -7,8 +7,8 @@ import { ConfirmProvider } from "@/components/confirm-provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "NoteAI - 轻量级个人笔记网站",
description: "基于 Next.js 的 AI 驱动笔记应用",
title: "NoteAI - 轻量级个人笔记应用",
description: "基于 Next.js 的 AI 驱动笔记工具",
};
export default function RootLayout({
@@ -19,12 +19,7 @@ export default function RootLayout({
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<ConfirmProvider>{children}</ConfirmProvider>
</ThemeProvider>
</body>
+5 -5
View File
@@ -39,9 +39,9 @@ export default function LoginPage() {
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="ui-card ui-enter w-full max-w-md space-y-8 p-8 shadow-xl">
<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">
<div className="ui-float inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4 shadow-sm">
<Sparkles size={32} />
</div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
@@ -57,7 +57,7 @@ export default function LoginPage() {
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"
className="ui-input pl-10 pr-4 py-3"
required
/>
</div>
@@ -79,7 +79,7 @@ export default function LoginPage() {
<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"
className="ui-select w-auto py-1 text-xs"
>
<option value="1">1</option>
<option value="7">7</option>
@@ -90,7 +90,7 @@ export default function LoginPage() {
<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"
className="ui-btn-primary w-full py-3 font-semibold shadow-md"
>
</button>
+173 -195
View File
@@ -1,117 +1,118 @@
"use client";
"use client";
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
import { Editor } from "@/components/editor";
import { useEditorStore } from "@/lib/store";
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock } from "lucide-react";
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen } from "lucide-react";
import { useSettingsStore } from "@/lib/settings-store";
import { exportPageAsMarkdown } from "@/lib/export";
import { useState } from "react";
import { useMemo, 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";
const ICONS = [
"📄", "📝", "📒", "📘", "📙", "📕", "📗", "📚",
"💡", "🔥", "✅", "❗", "⭐", "📌", "📎", "🧠",
"🎯", "🚀", "⚙️", "🧪", "💼", "📊", "📈", "🧩",
"🌱", "🌟", "🎨", "🎵", "🎬", "📷", "💬", "🔖",
"⌛", "🗂️", "📦", "🏷️", "🗓️", "🕒", "🔍", "🧾",
];
export default function Home() {
const { activePageId, pages, updatePage } = useEditorStore();
const { openSearchWithTag } = useSearchStore();
const activePage = pages.find(p => p.id === activePageId);
const { timezone } = useSettingsStore();
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("");
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
const breadcrumbs = useMemo(() => {
if (!activePage) return [];
const result = [] as typeof pages;
let current = activePage;
while (current) {
result.unshift(current);
if (!current.parentId) break;
const parent = pages.find((p) => p.id === current.parentId);
if (!parent) break;
current = parent;
}
return result;
}, [activePage, pages]);
const addTag = () => {
if (!activePage) return;
const next = tagInput.trim();
if (!next) return;
const merged = Array.from(new Set([...(activePage.tags || []), next]));
updatePage(activePage.id, { tags: merged });
setTagInput("");
setIsAddingTag(false);
};
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"
)}>
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
<div className={cn(activePageId ? "hidden" : "block h-full flex-1 md:hidden")}>
<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"
)}>
<main className={cn("relative z-0 flex h-full flex-1 flex-col overflow-hidden", !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">
<div className="mx-auto min-h-screen max-w-7xl px-4 py-6 md:px-16">
<div className="ui-enter group relative mb-8">
<button
className="absolute -top-12 left-0 flex items-center gap-1 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground md:hidden"
onClick={() => useEditorStore.getState().setActivePageId(null)}
>
<ChevronLeft size={18} />
</button>
<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 className="mb-4 flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
{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(
"flex items-center gap-1 transition-colors hover:text-foreground hover:underline",
crumb.id === activePage.id && "pointer-events-none font-medium text-foreground"
)}
>
{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>
)}
{activePage.icon && <span className="select-none text-3xl">{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"
"w-full border-none bg-transparent text-3xl font-bold text-foreground outline-none placeholder:text-muted-foreground/30",
activePage.isLocked && "cursor-not-allowed select-none opacity-80"
)}
/>
</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">
<div className="ui-enter-delayed mb-6 flex flex-wrap items-center gap-2">
{activePage.tags?.map((tag) => {
const colors = getTagColor(tag);
return (
@@ -119,151 +120,127 @@ export default function Home() {
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
)}>
"group/tag inline-flex items-center gap-1 rounded-[4px] border px-2.5 py-1 text-[11px] font-medium 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) || [];
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"
className="ml-1 rounded-full p-0.5 opacity-0 transition-all group-hover/tag:opacity-100 hover:bg-black/10 dark:hover:bg-white/10"
title="移除标签"
>
<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)) });
}
{isAddingTag ? (
<input
autoFocus
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onBlur={addTag}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
if (e.key === "Escape") {
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>
}
}}
className="w-28 rounded-md border border-primary/40 bg-background px-2 py-1 text-xs outline-none focus:ring-2 focus:ring-primary/20"
placeholder="输入标签"
/>
) : (
<button
onClick={() => setIsAddingTag(true)}
className="ui-btn-secondary px-2 py-1 text-xs text-muted-foreground"
>
<Plus size={12} />
</button>
)}
{/* Icon Picker */}
<div className="relative group/icon-picker">
<div className="relative">
<button
onClick={() => setIsIconPickerOpen((v) => !v)}
className="ui-btn-secondary px-2 py-1 text-xs text-muted-foreground"
>
<Sparkles size={12} /> {activePage.icon ? "更换图标" : "添加图标"}
</button>
{isIconPickerOpen && (
<div className="ui-card absolute left-0 top-full z-50 mt-1 w-80 p-2 shadow-xl animate-in fade-in zoom-in-95">
<div className="grid h-48 grid-cols-8 gap-1 overflow-y-auto pr-1">
{ICONS.map((icon) => (
<button
key={icon}
onClick={() => {
updatePage(activePage.id, { icon });
setIsIconPickerOpen(false);
}}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md text-lg transition-colors hover:bg-accent",
activePage.icon === icon && "bg-accent"
)}
>
{icon}
</button>
))}
<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"
onClick={() => {
updatePage(activePage.id, { icon: null });
setIsIconPickerOpen(false);
}}
className="flex h-8 w-8 items-center justify-center rounded-md text-destructive transition-colors hover:bg-destructive/10"
title="清除图标"
>
<Sparkles size={12} />
{activePage.icon ? '更改图标' : '添加图标'}
<X size={14} />
</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>
<button
onClick={() => updatePage(activePage.id, { isLocked: !activePage.isLocked })}
className={cn(
"ui-btn-secondary px-2 py-1 text-xs",
activePage.isLocked ? "border-orange-200 bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-300" : "text-muted-foreground"
)}
title={activePage.isLocked ? "解锁编辑" : "锁定编辑,防止误触"}
>
{activePage.isLocked ? <Lock size={12} /> : <Unlock size={12} />}
{activePage.isLocked ? "已锁定" : "锁定"}
</button>
{activePage.updatedAt && (
<div className="ml-1 h-4 border-l pl-2 text-xs text-muted-foreground/50">
{new Date(activePage.updatedAt).toLocaleString("zh-CN", {
timeZone: timezone || "Asia/Shanghai",
hour12: false,
})}
</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 ... */}
{activePage.type === "folder" ? (
<div className="ui-card ui-enter flex h-[50vh] flex-col items-center justify-center gap-3 text-muted-foreground">
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
<FolderOpen size={34} className="text-primary/80" />
</div>
<p className="text-base font-medium text-foreground"></p>
<p className="text-sm text-muted-foreground"></p>
</div>
) : (
<div className="min-h-[60vh] pb-24">
@@ -280,17 +257,18 @@ export default function Home() {
</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 className="ui-enter flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
<span className="text-4xl">🧠</span>
</div>
<div className="text-center space-y-1">
<div className="space-y-1 text-center">
<h3 className="text-lg font-semibold text-foreground">使 NoteAI</h3>
<p className="text-sm text-muted-foreground/80"></p>
<p className="text-sm text-muted-foreground/90"></p>
</div>
</div>
)}
</main>
<AIChatPanel editor={editor} isOpen={isChatOpen} onClose={() => setIsChatOpen(false)} />
</div>
</ImportProvider>
+20 -20
View File
@@ -66,12 +66,12 @@ export default function SettingsPage() {
<ResizableSidebar />
<main className="flex-1 h-full overflow-y-auto p-4 md:p-10">
<div className="mx-auto max-w-3xl space-y-8 pb-16">
<header className="space-y-2">
<header className="ui-enter space-y-2">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">AI</p>
</header>
<section className="rounded-xl border bg-card p-6 space-y-5">
<section className="ui-card ui-enter-delayed p-6 space-y-5">
<div className="flex items-center gap-2 border-b pb-2">
<Type size={18} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
@@ -83,7 +83,7 @@ export default function SettingsPage() {
<select
value={fontFamily}
onChange={(e) => setFontFamily(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
className="ui-select"
>
{fontOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -102,7 +102,7 @@ export default function SettingsPage() {
step="1"
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
className="w-full"
className="w-full accent-primary"
/>
</label>
@@ -115,7 +115,7 @@ export default function SettingsPage() {
step="0.1"
value={lineHeight}
onChange={(e) => setLineHeight(parseFloat(e.target.value))}
className="w-full"
className="w-full accent-primary"
/>
</label>
@@ -128,7 +128,7 @@ export default function SettingsPage() {
step="0.1"
value={tableLineHeight}
onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
className="w-full"
className="w-full accent-primary"
/>
</label>
</div>
@@ -138,7 +138,7 @@ export default function SettingsPage() {
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
className="ui-select"
>
{timezoneOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -149,7 +149,7 @@ export default function SettingsPage() {
</label>
</section>
<section className="rounded-xl border bg-card p-6 space-y-5">
<section className="ui-card ui-enter-delayed p-6 space-y-5">
<div className="flex items-center gap-2 border-b pb-2">
<Sparkles size={18} className="text-primary" />
<h2 className="text-xl font-semibold">AI </h2>
@@ -162,7 +162,7 @@ export default function SettingsPage() {
type="text"
value={aiConfig.baseURL}
onChange={(e) => setAIConfig({ baseURL: e.target.value })}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
className="ui-input"
placeholder="https://api.openai.com/v1"
/>
</label>
@@ -173,7 +173,7 @@ export default function SettingsPage() {
type="password"
value={aiConfig.apiKey}
onChange={(e) => setAIConfig({ apiKey: e.target.value })}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
className="ui-input"
placeholder="sk-..."
/>
</label>
@@ -184,18 +184,18 @@ export default function SettingsPage() {
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"
className="ui-input"
placeholder="gpt-4o-mini"
/>
</label>
</div>
</section>
<section className="rounded-xl border bg-card p-6">
<section className="ui-card ui-enter-delayed p-6">
<PromptManagement />
</section>
<section className="rounded-xl border bg-card p-6 space-y-5">
<section className="ui-card ui-enter-delayed 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>
@@ -206,7 +206,7 @@ export default function SettingsPage() {
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
className="ui-input"
placeholder="当前密码"
required
/>
@@ -214,7 +214,7 @@ export default function SettingsPage() {
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
className="ui-input"
placeholder="新密码"
required
/>
@@ -222,7 +222,7 @@ export default function SettingsPage() {
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
className="ui-input"
placeholder="确认新密码"
required
/>
@@ -235,7 +235,7 @@ export default function SettingsPage() {
<button
type="submit"
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"
className="ui-btn-primary"
>
<Save size={14} />
@@ -243,7 +243,7 @@ export default function SettingsPage() {
</form>
</section>
<section className="rounded-xl border bg-card p-6 space-y-4">
<section className="ui-card ui-enter-delayed p-6 space-y-4">
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground"></p>
@@ -254,13 +254,13 @@ export default function SettingsPage() {
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"
className="ui-btn-secondary"
>
<Download size={14} />
(Zip)
</button>
<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">
<label className="ui-btn inline-flex cursor-pointer border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20">
<Upload size={14} />
<input
+20 -23
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import React from "react";
import { Sparkles, Wand2 } from "lucide-react";
@@ -15,41 +15,38 @@ export function AIAssist({ onSuggest, isOpen, onClose }: AIAssistProps) {
const { prompts } = useSettingsStore();
if (!isOpen) return null;
// Check if we are in browser to avoid CSR mismatches, though useClient handles most.
if (typeof document === 'undefined') return null;
if (typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/10 backdrop-blur-[1px]" onClick={onClose}>
<div
className="bg-popover border rounded-lg shadow-xl w-72 overflow-hidden animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="p-3 border-b bg-muted/50 flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Sparkles size={16} className="text-blue-500" />
AI
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/25 backdrop-blur-[2px]" onClick={onClose}>
<div className="ui-card w-[320px] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b border-border/60 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<Sparkles size={16} className="text-primary" />
AI
</div>
<div className="p-1.5 space-y-0.5 max-h-[300px] overflow-y-auto scrollbar-thin">
<div className="max-h-[320px] space-y-1 overflow-y-auto p-2">
{prompts.map((prompt) => (
<button
key={prompt.id}
onClick={() => onSuggest(prompt)}
className="w-full flex items-start gap-3 p-2.5 hover:bg-muted/80 rounded-md transition-colors text-left group"
className="w-full rounded-lg p-2.5 text-left transition-colors hover:bg-muted/70"
>
<div className="mt-0.5 p-1.5 bg-primary/10 rounded-md group-hover:bg-primary/20 transition-colors">
<Wand2 size={16} className="text-primary" />
</div>
<div>
<div className="text-sm font-medium text-foreground">{prompt.label}</div>
<div className="text-[11px] text-muted-foreground">{prompt.description}</div>
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<Wand2 size={16} className="text-primary" />
</div>
<div>
<div className="text-sm font-medium text-foreground">{prompt.label}</div>
<div className="text-[11px] text-muted-foreground">{prompt.description}</div>
</div>
</div>
</button>
))}
</div>
<div className="p-2 bg-muted/20 border-t">
<div className="flex items-center justify-between text-[10px] text-muted-foreground px-1">
<div className="border-t border-border/60 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
<div className="flex items-center justify-between">
<span>AI Powered</span>
<span className="opacity-50">ESC to close</span>
<span className="opacity-70"> ESC </span>
</div>
</div>
</div>
+77 -84
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import { useState, useRef, useEffect } from "react";
import { Send, User, Bot, Copy, FileText, X, Sparkles, Eraser } from "lucide-react";
@@ -26,28 +26,19 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [useContext, setUseContext] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [tokenCount, setTokenCount] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// ... (rest of the code)
// Simple token estimation: ~4 chars per token for English, ~1 char per token for Chinese
useEffect(() => {
const text = messages.map(m => m.content).join("") + input;
const text = messages.map((m) => m.content).join("") + input;
setTokenCount(Math.ceil(text.length * 0.7));
}, [messages, input]);
// Scroll to bottom effect
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -59,35 +50,35 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
const handleSend = async () => {
if (!input.trim() || isLoading) return;
if (!aiConfig.apiKey) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Error: No API Key configured. Please go to Settings > AI Config." }]);
setMessages((prev) => [
...prev,
{ id: Date.now().toString(), role: "system", content: "未配置 API Key,请先到设置页完成配置。" },
]);
return;
}
const userMsg: Message = { id: Date.now().toString(), role: "user", content: input };
setMessages(prev => [...prev, userMsg]);
setMessages((prev) => [...prev, userMsg]);
setInput("");
setIsLoading(true);
abortControllerRef.current = new AbortController();
const contextMsg: Message | null = (useContext && editor)
? { id: "context", role: "system", content: `Current Document Context:\n${editor.getText().slice(0, 4000)}...` }
: null;
const contextMsg: Message | null =
useContext && editor
? { id: "context", role: "system", content: `当前文档上下文:\n${editor.getText().slice(0, 4000)}...` }
: null;
const apiMessages = [
...(contextMsg ? [contextMsg] : []),
...messages.filter(m => m.role !== "system"),
userMsg
];
const apiMessages = [...(contextMsg ? [contextMsg] : []), ...messages.filter((m) => m.role !== "system"), userMsg];
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: apiMessages.map(m => ({ role: m.role, content: m.content })),
config: aiConfig
messages: apiMessages.map((m) => ({ role: m.role, content: m.content })),
config: aiConfig,
}),
signal: abortControllerRef.current.signal
signal: abortControllerRef.current.signal,
});
if (!res.ok) throw new Error(res.statusText);
@@ -96,8 +87,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
const assistantMsg: Message = { id: (Date.now() + 1).toString(), role: "assistant", content: "" };
setMessages(prev => [...prev, assistantMsg]);
setMessages((prev) => [...prev, assistantMsg]);
while (true) {
const { done, value } = await reader.read();
@@ -107,28 +97,34 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
const lines = chunk.split("\n\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content || "";
if (content) {
assistantMsg.content += content;
setMessages(prev => prev.map(m => m.id === assistantMsg.id ? { ...assistantMsg } : m));
}
} catch (e) {
console.error("Parse error", e);
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content || "";
if (content) {
assistantMsg.content += content;
setMessages((prev) => prev.map((m) => (m.id === assistantMsg.id ? { ...assistantMsg } : m)));
}
} catch (e) {
console.error("Parse error", e);
}
}
}
} catch (e: unknown) {
const isAbortError = e instanceof Error && e.name === "AbortError";
if (isAbortError) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Genertion stopped by user." }]);
setMessages((prev) => [...prev, { id: Date.now().toString(), role: "system", content: "生成已停止。" }]);
} else {
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: `Error: ${e instanceof Error ? e.message : "Unknown error"}` }]);
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
role: "system",
content: `请求失败:${e instanceof Error ? e.message : "未知错误"}`,
},
]);
}
} finally {
setIsLoading(false);
@@ -145,70 +141,68 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
if (!isOpen) return null;
return (
<div className="fixed right-0 top-0 bottom-0 w-96 bg-background border-l shadow-xl z-50 flex flex-col animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="fixed bottom-0 right-0 top-0 z-50 flex w-96 flex-col border-l border-border/70 bg-background shadow-xl animate-in slide-in-from-right duration-300">
<div className="flex items-center justify-between border-b border-border/70 p-4">
<div className="flex items-center gap-2">
<Sparkles className="text-primary w-5 h-5" />
<Sparkles className="h-5 w-5 text-primary" />
<div>
<h2 className="font-semibold text-lg leading-none">AI </h2>
<p className="text-[10px] text-muted-foreground mt-0.5 font-mono opacity-80">
{aiConfig.model || "gpt-3.5-turbo"}
</p>
<h2 className="text-lg font-semibold leading-none">AI </h2>
<p className="mt-0.5 font-mono text-[10px] text-muted-foreground opacity-80">{aiConfig.model || "gpt-3.5-turbo"}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground mr-2">Est. Tokens: {tokenCount}</span>
<button onClick={onClose} className="p-1 hover:bg-muted rounded-md transition-colors">
<span className="mr-1 text-xs text-muted-foreground"> Tokens: {tokenCount}</span>
<button onClick={onClose} className="ui-icon-btn h-8 w-8">
<X size={18} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-20">
<div className="mt-20 text-center text-muted-foreground">
<p></p>
<p className="text-sm mt-2"></p>
<p className="mt-2 text-sm"></p>
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className={cn("flex gap-3", msg.role === "user" ? "flex-row-reverse" : "")}>
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}>
<div key={msg.id} className={cn("flex gap-3", msg.role === "user" ? "flex-row-reverse" : "")}>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
{msg.role === "user" ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={cn(
"group relative max-w-[85%] rounded-lg p-3 text-sm",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
msg.role === "system" && "bg-destructive/10 text-destructive w-full max-w-full"
)}>
{/* Markdown Rendering for Assistant */}
<div
className={cn(
"group relative max-w-[85%] rounded-lg p-3 text-sm",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
msg.role === "system" && "w-full max-w-full bg-destructive/10 text-destructive"
)}
>
{msg.role === "assistant" ? (
<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 prose-sm max-w-none break-words dark:prose-invert [&>ol]:list-decimal [&>ol]:pl-4 [&>p]:mb-2 [&>ul]:list-disc [&>ul]:pl-4"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(marked.parse(msg.content) as string) }}
/>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
{/* Assistant Actions */}
{msg.role === "assistant" && !isLoading && (
<div className="absolute -bottom-6 left-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<div className="absolute -bottom-6 left-0 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => handleInsert(msg.content)}
className="p-1 text-xs bg-background border rounded shadow hover:bg-muted flex items-center gap-1"
title="插入到光标位置"
className="flex items-center gap-1 rounded border bg-background px-1 py-1 text-xs shadow hover:bg-muted"
title="插入到编辑器"
>
<FileText size={12} />
</button>
<button
onClick={() => navigator.clipboard.writeText(msg.content)}
className="p-1 text-xs bg-background border rounded shadow hover:bg-muted"
className="rounded border bg-background p-1 text-xs shadow hover:bg-muted"
title="复制"
>
<Copy size={12} />
@@ -221,21 +215,20 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 border-t bg-muted/20">
<div className="flex items-center justify-between mb-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<div className="border-t border-border/70 bg-muted/20 p-4">
<div className="mb-2 flex items-center justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={useContext}
onChange={(e) => setUseContext(e.target.checked)}
className="rounded border-gray-300 text-primary focus:ring-primary"
className="rounded border-input text-primary focus:ring-primary"
/>
</label>
<button
onClick={() => setMessages([])}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
title="清空对话"
>
<Eraser size={12} />
@@ -252,18 +245,18 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
}
}}
placeholder="输入消息..."
className="w-full resize-none rounded-md border bg-background p-3 pr-10 text-sm focus:outline-none focus:ring-1 focus:ring-primary min-h-[80px]"
className="min-h-[86px] w-full resize-none rounded-md border border-input bg-background p-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-primary"
/>
<button
onClick={isLoading ? handleStop : handleSend}
disabled={!input.trim() && !isLoading}
className={cn(
"absolute right-2 bottom-2 p-2 rounded-md hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all",
"absolute bottom-2 right-2 rounded-md p-2 transition-all disabled:cursor-not-allowed disabled:opacity-50",
isLoading ? "bg-red-500 text-white" : "bg-primary text-primary-foreground"
)}
title={isLoading ? "停止生成" : "发送"}
>
{isLoading ? <div className="h-4 w-4 bg-current rounded-sm animate-pulse" /> : <Send size={16} />}
{isLoading ? <div className="h-4 w-4 animate-pulse rounded-sm bg-current" /> : <Send size={16} />}
</button>
</div>
</div>
+16 -23
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
@@ -31,13 +31,16 @@ 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 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) => {
@@ -58,11 +61,9 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
}}
>
<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>
<Dialog.Overlay className="fixed inset-0 z-[100] bg-black/45 backdrop-blur-[2px] 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 ui-card 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}
@@ -70,21 +71,13 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
)}
<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"
>
<button type="button" onClick={() => closeWith(false)} className="ui-btn-secondary">
{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"
}
className={request?.tone === "danger" ? "ui-btn-danger" : "ui-btn-primary"}
>
{request?.confirmText || "确认"}
</button>
+13 -16
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import { useSearchStore } from "@/lib/search-store";
import { useEffect } from "react";
@@ -9,6 +9,7 @@ import { Search, FileText, Folder } from "lucide-react";
export function SearchCommand() {
const { isOpen, setOpen, query, setQuery } = useSearchStore();
const { pages, setActivePageId } = useEditorStore();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@@ -29,34 +30,30 @@ export function SearchCommand() {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
<div
className="fixed inset-0"
onClick={() => setOpen(false)}
/>
<div className="relative w-full max-w-lg bg-popover border rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm animate-in fade-in duration-200">
<div className="fixed inset-0" onClick={() => setOpen(false)} />
<div className="ui-card relative w-full max-w-lg overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<Command className="w-full">
<div className="flex items-center border-b px-3">
<div className="flex items-center border-b border-border/70 px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
placeholder="搜索文档..."
placeholder="搜索文档或标签..."
value={query}
onValueChange={setQuery}
className="flex h-12 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
className="flex h-12 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<Command.List className="max-h-[300px] overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-muted-foreground"></Command.Empty>
<Command.List className="max-h-[320px] overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-muted-foreground"></Command.Empty>
<Command.Group heading="文档">
{pages.map((page) => (
<Command.Item
key={page.id}
value={`${page.title} ${page.tags?.join(" ")}`} // Search by title AND tags
value={`${page.title} ${(page.tags || []).join(" ")}`}
onSelect={() => runCommand(() => setActivePageId(page.id))}
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm cursor-pointer hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground transition-colors"
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
>
{page.type === 'folder' ? <Folder size={14} className="text-blue-400" /> : <FileText size={14} />}
{page.type === "folder" ? <Folder size={14} className="text-blue-500" /> : <FileText size={14} />}
<span>{page.title || "无标题"}</span>
</Command.Item>
))}
+79 -73
View File
@@ -1,10 +1,12 @@
"use client";
"use client";
import { useState } from "react";
import { Plus, Trash2, Edit2, X, Sparkles } from "lucide-react";
import { useSettingsStore, AIPrompt } from "@/lib/settings-store";
import { useConfirm } from "@/components/confirm-provider";
export function PromptManagement() {
const confirm = useConfirm();
const { prompts, addPrompt, updatePrompt, deletePrompt, resetPrompts } = useSettingsStore();
const [editing, setEditing] = useState<AIPrompt | null>(null);
const [isNew, setIsNew] = useState(false);
@@ -12,10 +14,17 @@ export function PromptManagement() {
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!editing) return;
const label = editing.label.trim();
const id = editing.id.trim();
const systemPrompt = editing.systemPrompt.trim();
if (!label || !id || !systemPrompt) return;
const payload = { ...editing, label, id, systemPrompt };
if (isNew) {
addPrompt(editing);
addPrompt(payload);
} else {
updatePrompt(editing.id, editing);
updatePrompt(payload.id, payload);
}
setEditing(null);
setIsNew(false);
@@ -25,140 +34,137 @@ export function PromptManagement() {
setEditing({
id: `custom_${Date.now()}`,
label: "新功能",
description: "描述...",
systemPrompt: "你是...",
description: "功能描述...",
systemPrompt: "你是一个专业助手,请根据用户输入完成任务。",
});
setIsNew(true);
};
return (
<section className="space-y-6">
<div className="flex items-center justify-between gap-2 pb-2 border-b">
<section className="space-y-5">
<div className="flex items-center justify-between gap-2 border-b border-border/70 pb-3">
<div className="flex items-center gap-2">
<Sparkles size={20} className="text-primary" />
<h2 className="text-xl font-semibold"> AI </h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={startNew}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
>
<button onClick={startNew} className="ui-btn-primary px-3 py-1.5 text-xs">
<Plus size={14} />
</button>
<button
onClick={resetPrompts}
className="px-3 py-1.5 text-xs font-medium border rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<button onClick={resetPrompts} className="ui-btn-secondary px-3 py-1.5 text-xs">
</button>
</div>
</div>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
{prompts.map((prompt) => (
<div key={prompt.id} className="flex items-start justify-between p-3 bg-muted/30 border rounded-lg group">
<div className="grid gap-3 md:grid-cols-2">
{prompts.map((prompt) => (
<div key={prompt.id} className="rounded-xl border border-border/70 bg-muted/25 p-3">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-medium text-sm flex items-center gap-2">
<h3 className="flex items-center gap-2 text-sm font-medium">
{prompt.label}
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground font-mono">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{prompt.id}
</span>
</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{prompt.description}
</p>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{prompt.description}</p>
</div>
<div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1">
<button
onClick={() => {
setEditing(prompt);
setIsNew(false);
}}
className="p-1.5 hover:bg-muted rounded text-muted-foreground hover:text-foreground"
className="ui-icon-btn h-8 w-8"
title="编辑"
>
<Edit2 size={14} />
</button>
<button
onClick={() => deletePrompt(prompt.id)}
className="p-1.5 hover:bg-red-500/10 rounded text-muted-foreground hover:text-red-500"
onClick={async () => {
const ok = await confirm({
title: "删除提示词",
description: `确认删除“${prompt.label}”吗?`,
confirmText: "删除",
cancelText: "取消",
tone: "danger",
});
if (ok) deletePrompt(prompt.id);
}}
className="ui-icon-btn h-8 w-8 hover:text-destructive"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Edit/Create Dialog (Simple Overlay) */}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-background border rounded-xl shadow-2xl w-full max-w-lg p-6 space-y-4 animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between pb-2 border-b">
<h3 className="font-semibold text-lg">{isNew ? "新建 AI 功能" : "编辑 AI 功能"}</h3>
<button onClick={() => setEditing(null)}>
<X size={20} />
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
<div className="ui-card w-full max-w-xl p-6 shadow-2xl animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/60 pb-3">
<h3 className="text-lg font-semibold">{isNew ? "新建 AI 提示词" : "编辑 AI 提示词"}</h3>
<button onClick={() => setEditing(null)} className="ui-icon-btn h-8 w-8">
<X size={18} />
</button>
</div>
<form onSubmit={handleSave} className="space-y-4">
<form onSubmit={handleSave} className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"> (Label)</label>
<label className="space-y-2 text-sm">
<span className="font-medium"></span>
<input
type="text"
value={editing.label}
onChange={e => setEditing({ ...editing!, label: e.target.value })}
className="w-full p-2 bg-muted/50 border rounded text-sm"
onChange={(e) => setEditing({ ...editing, label: e.target.value })}
className="ui-input"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"> (ID)</label>
</label>
<label className="space-y-2 text-sm">
<span className="font-medium"></span>
<input
type="text"
value={editing.id}
onChange={e => setEditing({ ...editing!, id: e.target.value })}
className="w-full p-2 bg-muted/50 border rounded text-sm"
onChange={(e) => setEditing({ ...editing, id: e.target.value })}
className="ui-input"
readOnly={!isNew}
required
/>
</div>
</label>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<label className="space-y-2 text-sm block">
<span className="font-medium"></span>
<input
type="text"
value={editing.description}
onChange={e => setEditing({ ...editing!, description: e.target.value })}
className="w-full p-2 bg-muted/50 border rounded text-sm"
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
className="ui-input"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"> (System Prompt)</label>
</label>
<label className="space-y-2 text-sm block">
<span className="font-medium"></span>
<textarea
value={editing.systemPrompt}
onChange={e => setEditing({ ...editing!, systemPrompt: e.target.value })}
className="w-full h-32 p-2 bg-muted/50 border rounded text-sm font-mono leading-relaxed"
onChange={(e) => setEditing({ ...editing, systemPrompt: e.target.value })}
className="ui-input min-h-32 font-mono"
required
/>
<p className="text-xs text-muted-foreground">
AI
</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setEditing(null)}
className="px-4 py-2 text-sm font-medium bg-muted hover:bg-muted/80 rounded"
>
<p className="text-xs text-muted-foreground"></p>
</label>
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={() => setEditing(null)} className="ui-btn-secondary">
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded"
>
<button type="submit" className="ui-btn-primary">
</button>
</div>
+124 -129
View File
@@ -1,8 +1,27 @@
"use client";
"use client";
import React, { useRef, useEffect, useState } from "react";
import { useEditorStore, Page } from "@/lib/store";
import { Plus, Search, PanelLeftClose, Sun, Moon, Upload, FolderPlus, FilePlus, Settings, Hash, Tag as TagIcon, FileText, Folder, LogOut, ChevronRight, ChevronDown, Layers, PanelLeftOpen } from "lucide-react";
import {
Search,
PanelLeftClose,
Sun,
Moon,
Upload,
FolderPlus,
FilePlus,
Settings,
Hash,
Tag as TagIcon,
FileText,
Folder,
LogOut,
ChevronRight,
ChevronDown,
Layers,
PanelLeftOpen,
Plus,
} from "lucide-react";
import { useTheme } from "next-themes";
import { TreeView } from "./sidebar/tree-view";
import Link from "next/link";
@@ -11,20 +30,29 @@ import { SearchCommand } from "@/components/search-command";
import { cn, getTagColor } from "@/lib/utils";
import { useSearchStore } from "@/lib/search-store";
import { useImport } from "@/components/import-context";
import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor, pointerWithin, DragStartEvent, useDroppable } from "@dnd-kit/core";
import {
DndContext,
DragEndEvent,
DragOverlay,
useSensor,
useSensors,
PointerSensor,
pointerWithin,
DragStartEvent,
useDroppable,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
function RootDropZone() {
const { setNodeRef, isOver } = useDroppable({
id: 'root-drop-zone',
});
const { setNodeRef, isOver } = useDroppable({ id: "root-drop-zone" });
return (
<div
ref={setNodeRef}
className={cn(
"flex-1 min-h-[50px] rounded-sm flex items-center justify-center text-xs text-muted-foreground/0 hover:text-muted-foreground/50 border-2 border-dashed border-transparent transition-all",
isOver && "bg-accent/30 border-primary/20 text-primary/70"
"flex min-h-[50px] flex-1 items-center justify-center rounded-md border-2 border-dashed border-transparent text-xs text-muted-foreground/0 transition-all",
"hover:text-muted-foreground/50",
isOver && "border-primary/20 bg-accent/30 text-primary/70"
)}
>
{isOver ? "移动到根目录" : ""}
@@ -33,7 +61,6 @@ function RootDropZone() {
}
export function ResizableSidebar() {
// State
const [width, setWidth] = useState(() => {
if (typeof window === "undefined") return 256;
const savedWidth = localStorage.getItem("sidebar-width");
@@ -46,13 +73,11 @@ export function ResizableSidebar() {
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement>(null);
// Save state
useEffect(() => {
localStorage.setItem('sidebar-width', width.toString());
localStorage.setItem('sidebar-collapsed', isCollapsed.toString());
localStorage.setItem("sidebar-width", width.toString());
localStorage.setItem("sidebar-collapsed", isCollapsed.toString());
}, [width, isCollapsed]);
// Resizing Logic
const startResizing = React.useCallback(() => {
setIsResizing(true);
}, []);
@@ -61,14 +86,16 @@ export function ResizableSidebar() {
setIsResizing(false);
}, []);
const resize = React.useCallback((mouseMoveEvent: MouseEvent) => {
if (isResizing) {
const resize = React.useCallback(
(mouseMoveEvent: MouseEvent) => {
if (!isResizing) return;
const newWidth = mouseMoveEvent.clientX;
if (newWidth >= 200 && newWidth <= 480) {
if (newWidth >= 220 && newWidth <= 480) {
setWidth(newWidth);
}
}
}, [isResizing]);
},
[isResizing]
);
useEffect(() => {
window.addEventListener("mousemove", resize);
@@ -83,32 +110,30 @@ export function ResizableSidebar() {
<aside
ref={sidebarRef}
className={cn(
"hidden md:flex border-r bg-secondary h-screen flex-col group/sidebar relative z-20",
"relative z-20 hidden h-screen flex-col border-r bg-secondary md:flex",
isResizing ? "transition-none" : "transition-[width] duration-300 ease-in-out",
isCollapsed && "w-[0px] border-none"
)}
style={{ width: isCollapsed ? 0 : width }}
>
{/* Collapse Toggle (Desktop only) */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
"absolute -right-3 top-6 z-50 p-1 rounded-full bg-border text-foreground hover:bg-primary hover:text-primary-foreground border shadow-sm transition-all opacity-0 group-hover/sidebar:opacity-100",
isCollapsed && "opacity-100 -right-8" // Visible when collapsed
"absolute -right-3 top-6 z-50 rounded-full border bg-card p-1 text-foreground shadow-sm transition-all opacity-0 group-hover/sidebar:opacity-100 hover:bg-primary hover:text-primary-foreground",
isCollapsed && "-right-8 opacity-100"
)}
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
>
{isCollapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />}
</button>
<div className={cn("flex-1 flex flex-col overflow-hidden", isCollapsed ? "hidden" : "visible")}>
<div className={cn("flex flex-1 flex-col overflow-hidden", isCollapsed ? "hidden" : "visible")}>
<SidebarContent />
</div>
{/* Resize Handle */}
{!isCollapsed && (
<div
className="absolute right-0 top-0 w-1 h-full cursor-col-resize hover:bg-primary/50 transition-colors z-40"
className="absolute right-0 top-0 z-40 h-full w-1 cursor-col-resize transition-colors hover:bg-primary/50"
onMouseDown={startResizing}
/>
)}
@@ -122,28 +147,30 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
const { triggerImport, isImporting } = useImport();
const { theme, setTheme } = useTheme();
const router = useRouter();
const [mounted, setMounted] = React.useState(false);
// Collapsible State
const [isWorkspaceOpen, setIsWorkspaceOpen] = React.useState(true);
const [isTagsOpen, setIsTagsOpen] = React.useState(true);
React.useEffect(() => {
setMounted(true);
}, []);
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh(); // Refresh to ensure middleware runs
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
} catch (error) {
console.error("Logout failed", error);
// Fallback redirect
router.push('/login');
router.push("/login");
}
};
// DnD Sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px movement to start drag (prevents accidental drags on click)
distance: 8,
},
})
);
@@ -154,106 +181,82 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
const activeId = active.id as string;
const overId = over.id as string;
if (activeId === overId) return;
// Check if moving to root
if (overId === 'root-drop-zone') {
if (overId === "root-drop-zone") {
movePage(activeId, null);
return;
}
// Check relationship: Reparenting vs Reordering
const activeNode = pages.find(p => p.id === activeId);
const overNode = pages.find(p => p.id === overId);
const activeNode = pages.find((p) => p.id === activeId);
const overNode = pages.find((p) => p.id === overId);
if (!activeNode || !overNode) return;
// If 'over' is a folder and logic dictates "drop INTO", do move
// But dnd-kit sortable also triggers over events for items.
// We need a way to distinguish "Into Folder" vs "Sort Next To".
// Often 'center' collision is sort, maybe explicit 'folder' drop zone needed?
// For now, let's assume if dropping on a Folder that is NOT same parent, it implies move ONTO.
// If dropping on a sibling (same parent), it is sort.
// Prioritize moving file into folder if dropping ON a folder
if (activeNode.type === 'file' && overNode.type === 'folder' && activeNode.parentId !== overNode.id) {
if (activeNode.type === "file" && overNode.type === "folder" && activeNode.parentId !== overNode.id) {
movePage(activeId, overId);
return;
}
const isSameParent = activeNode.parentId === overNode.parentId;
if (isSameParent) {
// Reordering
// Get all siblings
const siblings = pages.filter(p => p.parentId === activeNode.parentId).sort((a, b) => (a.order || 0) - (b.order || 0));
const oldIndex = siblings.findIndex(p => p.id === activeId);
const newIndex = siblings.findIndex(p => p.id === overId);
const siblings = pages
.filter((p) => p.parentId === activeNode.parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
const oldIndex = siblings.findIndex((p) => p.id === activeId);
const newIndex = siblings.findIndex((p) => p.id === overId);
if (oldIndex !== newIndex) {
const newOrder = arrayMove(siblings, oldIndex, newIndex);
// Assign new order values
const updates = newOrder.map((p, index) => ({ id: p.id, order: index }));
reorderPages(updates);
}
} else if (overNode.type === "folder") {
movePage(activeId, overId);
} else {
// Reparenting
if (overNode.type === 'folder') {
// Drop ON folder -> Move INTO folder
movePage(activeId, overId);
} else {
// Drop ON file (in different group) -> Move to SIBLING (adopt overNode's parent)
// This allows moving back to root by dropping on a root file
movePage(activeId, overNode.parentId);
}
movePage(activeId, overNode.parentId);
}
};
const [activeDragItem, setActiveDragItem] = React.useState<Page | null>(null);
const onDragStart = (event: DragStartEvent) => {
const item = pages.find(p => p.id === event.active.id);
const item = pages.find((p) => p.id === event.active.id);
if (item) setActiveDragItem(item);
}
};
useEffect(() => {
fetchPages();
}, [fetchPages]);
// Handle clicks that should close mobile sidebar
const handleItemClick = () => {
if (onCloseMobile) onCloseMobile();
}
};
return (
<div className="flex flex-col h-full bg-secondary">
{/* Header */}
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold text-foreground/80 hover:text-foreground transition-colors cursor-pointer">
<div className="w-6 h-6 bg-primary rounded-md flex items-center justify-center text-primary-foreground text-[10px] shadow-sm">AI</div>
<div className="flex h-full flex-col bg-secondary">
<div className="flex items-center justify-between p-4">
<div className="flex cursor-pointer items-center gap-2 font-semibold text-foreground/80 transition-colors hover:text-foreground">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-[10px] text-primary-foreground shadow-sm">AI</div>
<span className="tracking-tight">NoteAI</span>
</div>
<div className="flex items-center gap-1 transition-opacity">
<div className="flex items-center gap-1">
<button
onClick={() => addPage(null, 'file')}
className="p-1.5 hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-all hover:text-foreground"
onClick={() => addPage(null, "file")}
className="ui-icon-btn h-8 w-8"
title="新建文件"
>
<FilePlus size={16} />
</button>
<button
onClick={() => addPage(null, 'folder')}
className="p-1.5 hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-all hover:text-foreground"
onClick={() => addPage(null, "folder")}
className="ui-icon-btn h-8 w-8"
title="新建文件夹"
>
<FolderPlus size={16} />
</button>
{/* Mobile Close Button (Added to layout properly) */}
{onCloseMobile && (
<button
onClick={onCloseMobile}
className="p-1.5 ml-1 bg-muted/50 hover:bg-destructive/10 text-muted-foreground hover:text-destructive rounded-md transition-all"
className="ui-icon-btn h-8 w-8 hover:text-destructive"
title="关闭菜单"
>
<PanelLeftClose size={16} />
@@ -262,30 +265,27 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
</div>
</div>
{/* Main Actions */}
<div className="px-3 space-y-1 mb-4">
<div className="mb-4 space-y-1 px-3">
<SearchCommand />
<button
onClick={() => {
setOpen(true);
handleItemClick(); // Close sidebar on mobile when opening global search
handleItemClick();
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-colors group"
className="group flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/60"
>
<Search size={16} className="group-hover:text-foreground" />
<span className="group-hover:text-foreground"></span>
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
<span className="text-xs"></span>K
<kbd className="ui-kbd ml-auto">
<span className="text-xs">Ctrl</span>K
</kbd>
</button>
</div>
{/* Pages List (Tree View) */}
{/* Pages List (Tree View) */}
<div className="flex-1 overflow-y-auto px-2 space-y-1">
<div className="flex-1 space-y-1 overflow-y-auto px-2">
<button
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
className="w-full px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider mb-1 flex items-center justify-between hover:text-foreground transition-colors group/workspace"
className="group/workspace mb-1 flex w-full items-center justify-between px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground/70 transition-colors hover:text-foreground"
>
<span className="flex items-center gap-1.5">
{isWorkspaceOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
@@ -294,10 +294,10 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
</span>
<Plus
size={14}
className="opacity-0 group-hover/workspace:opacity-100 hover:bg-muted/50 rounded cursor-pointer"
className="cursor-pointer rounded opacity-0 transition-opacity group-hover/workspace:opacity-100 hover:bg-muted/50"
onClick={(e) => {
e.stopPropagation();
addPage(null, 'file');
addPage(null, "file");
setIsWorkspaceOpen(true);
}}
/>
@@ -310,11 +310,8 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
) : pages.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
<p className="mb-2"></p>
<button
onClick={() => addPage(null, 'file')}
className="text-primary hover:underline"
>
?
<button onClick={() => addPage(null, "file")} className="text-primary hover:underline">
</button>
</div>
) : (
@@ -324,19 +321,21 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
onDragStart={onDragStart}
onDragEnd={handleDragEnd}
>
<div onClick={handleItemClick} className="flex-1 flex flex-col min-h-0">
<div onClick={handleItemClick} className="flex min-h-0 flex-1 flex-col">
<TreeView pages={pages} parentId={null} />
<RootDropZone />
</div>
<DragOverlay>
{activeDragItem ? (
<div className="px-2 py-1 text-sm rounded-md bg-popover shadow-lg border opacity-90 flex items-center gap-2">
<div className="flex items-center gap-2 rounded-md border bg-popover px-2 py-1 text-sm opacity-90 shadow-lg">
{activeDragItem.icon ? (
<span className="text-[16px] leading-none shrink-0 w-4 h-4 flex items-center justify-center grayscale-[0.2]">{activeDragItem.icon}</span>
) : activeDragItem.type === 'folder' ? (
<Folder size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
<span className="flex h-4 w-4 items-center justify-center text-[16px] leading-none grayscale-[0.2]">
{activeDragItem.icon}
</span>
) : activeDragItem.type === "folder" ? (
<Folder size={16} className="shrink-0 fill-indigo-500/20 text-indigo-500" />
) : (
<FileText size={16} className="text-muted-foreground/70 shrink-0" />
<FileText size={16} className="shrink-0 text-muted-foreground/70" />
)}
{activeDragItem.title}
</div>
@@ -348,11 +347,10 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
)}
</div>
{/* Tags List */}
<div className="px-2 py-2 border-t border-border/40">
<div className="border-t border-border/40 px-2 py-2">
<button
onClick={() => setIsTagsOpen(!isTagsOpen)}
className="w-full px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider mb-1 flex items-center justify-between hover:text-foreground transition-colors group/tags"
className="group/tags mb-1 flex w-full items-center justify-between px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground/70 transition-colors hover:text-foreground"
>
<span className="flex items-center gap-1.5">
{isTagsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
@@ -363,7 +361,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
{isTagsOpen && (
<div className="flex flex-wrap gap-1 px-2">
{Array.from(new Set(pages.flatMap(p => p.tags || []))).map(tag => {
{Array.from(new Set(pages.flatMap((p) => p.tags || []))).map((tag) => {
const colors = getTagColor(tag);
return (
<button
@@ -373,55 +371,52 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
handleItemClick();
}}
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,
"hover:scale-105 active:scale-95 duration-200"
"inline-flex items-center gap-1 rounded-[4px] border px-2.5 py-1 text-[11px] font-medium shadow-sm transition-transform duration-200 hover:scale-105 active:scale-95",
colors.bg,
colors.text,
colors.border
)}
title={`Filter by ${tag}`}
title={`按标签筛选:${tag}`}
>
<Hash size={12} className="opacity-70" />
{tag}
</button>
);
})}
{pages.flatMap(p => p.tags || []).length === 0 && (
<span className="text-[10px] text-muted-foreground/50 italic px-1"></span>
{pages.flatMap((p) => p.tags || []).length === 0 && (
<span className="px-1 text-[10px] italic text-muted-foreground/60"></span>
)}
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-3 border-t bg-muted/20 backdrop-blur-sm space-y-1">
<div className="space-y-1 border-t bg-muted/20 p-3 backdrop-blur-sm">
<button
onClick={() => triggerImport(null)}
disabled={isImporting}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-background rounded-md text-muted-foreground transition-all shadow-sm border border-transparent hover:border-border disabled:opacity-50"
className="ui-btn-secondary w-full justify-start border border-transparent text-muted-foreground hover:border-border hover:text-foreground disabled:opacity-50"
>
<Upload size={16} />
<span>{isImporting ? '导入中...' : '导入文档'}</span>
<span>{isImporting ? "导入中..." : "导入文档"}</span>
</button>
<div className="flex items-center gap-1">
<Link
href="/settings"
onClick={handleItemClick}
className="flex-1 flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-background rounded-md text-muted-foreground transition-all shadow-sm border border-transparent hover:border-border"
className="ui-btn-secondary flex-1 justify-start border border-transparent text-muted-foreground hover:border-border hover:text-foreground"
>
<Settings size={16} />
<span></span>
</Link>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-background rounded-md transition-all shadow-sm border border-transparent hover:border-border"
title={theme === 'dark' ? '切换到明亮模式' : '切换到暗黑模式'}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="ui-icon-btn"
title={mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
{mounted ? (theme === "dark" ? <Sun size={16} /> : <Moon size={16} />) : <Moon size={16} />}
</button>
<button
onClick={handleLogout}
className="p-2 text-muted-foreground hover:text-destructive hover:bg-background rounded-md transition-all shadow-sm border border-transparent hover:border-border"
title="退出登录"
>
<button onClick={handleLogout} className="ui-icon-btn hover:text-destructive" title="退出登录">
<LogOut size={16} />
</button>
</div>
+6 -6
View File
@@ -120,9 +120,9 @@ function TreeNode({
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="min-w-[120px] bg-popover text-popover-foreground rounded-md border shadow-md p-1 z-50 animate-in fade-in zoom-in-95" align="start">
<DropdownMenu.Content className="ui-card z-50 min-w-[160px] p-1.5 shadow-xl animate-in fade-in zoom-in-95" align="start" sideOffset={6}>
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === "folder" ? node.id : node.parentId;
@@ -135,7 +135,7 @@ function TreeNode({
<span></span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === "folder" ? node.id : node.parentId;
@@ -148,7 +148,7 @@ function TreeNode({
<span></span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === "folder" ? node.id : node.parentId;
@@ -160,7 +160,7 @@ function TreeNode({
<span>...</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
if (node.type === "folder") {
@@ -175,7 +175,7 @@ function TreeNode({
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-muted my-1" />
<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 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive outline-none transition-colors hover:bg-destructive/10"
onClick={async (e) => {
e.stopPropagation();
const ok = await confirm({