使用openai对UI进行了重构
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
Binary file not shown.
@@ -66,11 +66,95 @@
|
|||||||
body {
|
body {
|
||||||
background-color: hsl(var(--background));
|
background-color: hsl(var(--background));
|
||||||
color: hsl(var(--foreground));
|
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;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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 */
|
/* Editor Specific Styles for Notion-like feel */
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
+4
-9
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
@@ -7,8 +7,8 @@ import { ConfirmProvider } from "@/components/confirm-provider";
|
|||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "NoteAI - 轻量级个人笔记网站",
|
title: "NoteAI - 轻量级个人笔记应用",
|
||||||
description: "基于 Next.js 的 AI 驱动笔记应用",
|
description: "基于 Next.js 的 AI 驱动笔记工具",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -19,12 +19,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="zh-CN" suppressHydrationWarning>
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<ThemeProvider
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<ConfirmProvider>{children}</ConfirmProvider>
|
<ConfirmProvider>{children}</ConfirmProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<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="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} />
|
<Sparkles size={32} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">欢迎回来</h1>
|
<h1 className="text-3xl font-bold tracking-tight">欢迎回来</h1>
|
||||||
@@ -57,7 +57,7 @@ export default function LoginPage() {
|
|||||||
placeholder="访问密码"
|
placeholder="访问密码"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-3 bg-muted/50 border rounded-xl focus:ring-2 focus:ring-primary outline-none transition-all"
|
className="ui-input pl-10 pr-4 py-3"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@ export default function LoginPage() {
|
|||||||
<select
|
<select
|
||||||
value={duration}
|
value={duration}
|
||||||
onChange={(e) => setDuration(e.target.value)}
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs"
|
className="ui-select w-auto py-1 text-xs"
|
||||||
>
|
>
|
||||||
<option value="1">1天</option>
|
<option value="1">1天</option>
|
||||||
<option value="7">7天</option>
|
<option value="7">7天</option>
|
||||||
@@ -90,7 +90,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-3 bg-primary text-primary-foreground font-semibold rounded-xl hover:opacity-90 active:scale-[0.98] transition-all shadow-lg"
|
className="ui-btn-primary w-full py-3 font-semibold shadow-md"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+173
-195
@@ -1,117 +1,118 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
|
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
|
||||||
import { Editor } from "@/components/editor";
|
import { Editor } from "@/components/editor";
|
||||||
import { useEditorStore } from "@/lib/store";
|
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 { useSettingsStore } from "@/lib/settings-store";
|
||||||
import { exportPageAsMarkdown } from "@/lib/export";
|
import { exportPageAsMarkdown } from "@/lib/export";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { cn, getTagColor } from "@/lib/utils";
|
import { cn, getTagColor } from "@/lib/utils";
|
||||||
import { useSearchStore } from "@/lib/search-store";
|
import { useSearchStore } from "@/lib/search-store";
|
||||||
import { type Editor as TiptapEditor } from "@tiptap/react";
|
import { type Editor as TiptapEditor } from "@tiptap/react";
|
||||||
|
|
||||||
import { ImportProvider } from "@/components/import-context";
|
import { ImportProvider } from "@/components/import-context";
|
||||||
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
|
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
|
||||||
|
|
||||||
|
const ICONS = [
|
||||||
|
"📄", "📝", "📒", "📘", "📙", "📕", "📗", "📚",
|
||||||
|
"💡", "🔥", "✅", "❗", "⭐", "📌", "📎", "🧠",
|
||||||
|
"🎯", "🚀", "⚙️", "🧪", "💼", "📊", "📈", "🧩",
|
||||||
|
"🌱", "🌟", "🎨", "🎵", "🎬", "📷", "💬", "🔖",
|
||||||
|
"⌛", "🗂️", "📦", "🏷️", "🗓️", "🕒", "🔍", "🧾",
|
||||||
|
];
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { activePageId, pages, updatePage } = useEditorStore();
|
const { activePageId, pages, updatePage } = useEditorStore();
|
||||||
const { openSearchWithTag } = useSearchStore();
|
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 [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||||
const [tagInput, setTagInput] = useState("");
|
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 (
|
return (
|
||||||
<ImportProvider>
|
<ImportProvider>
|
||||||
<div className="flex h-[100dvh] w-full bg-background overflow-hidden relative">
|
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
|
||||||
{/* Mobile: Sidebar List View (Only visible when no page is active) */}
|
<div className={cn(activePageId ? "hidden" : "block h-full flex-1 md:hidden")}>
|
||||||
<div className={cn(
|
|
||||||
activePageId ? "hidden" : "flex-1 h-full md:hidden block"
|
|
||||||
)}>
|
|
||||||
<SidebarContent />
|
<SidebarContent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Sidebar (Resizable) */}
|
|
||||||
<ResizableSidebar />
|
<ResizableSidebar />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
<main className={cn("relative z-0 flex h-full flex-1 flex-col overflow-hidden", !activePageId && "hidden md:flex")}>
|
||||||
<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 ? (
|
{activePage ? (
|
||||||
<div className="flex-1 overflow-y-auto scroll-smooth">
|
<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">
|
<div className="mb-4 flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||||
{/* Mobile Back Button Integration in Header */}
|
{breadcrumbs.map((crumb, index) => (
|
||||||
<div className="md:hidden absolute -top-12 left-0 flex items-center gap-1 py-2 text-muted-foreground hover:text-foreground cursor-pointer"
|
<div key={crumb.id} className="flex items-center gap-1">
|
||||||
onClick={() => useEditorStore.getState().setActivePageId(null)}>
|
{index > 0 && <ChevronRight size={14} className="opacity-50" />}
|
||||||
<ChevronLeft size={20} />
|
<button
|
||||||
<span>返回列表</span>
|
onClick={() => useEditorStore.getState().setActivePageId(crumb.id)}
|
||||||
</div>
|
className={cn(
|
||||||
|
"flex items-center gap-1 transition-colors hover:text-foreground hover:underline",
|
||||||
{/* Breadcrumb Navigation */}
|
crumb.id === activePage.id && "pointer-events-none font-medium text-foreground"
|
||||||
<div className="flex items-center flex-wrap gap-1 text-sm text-muted-foreground mb-4">
|
)}
|
||||||
{(() => {
|
>
|
||||||
const breadcrumbs = [];
|
{crumb.icon && <span>{crumb.icon}</span>}
|
||||||
let current: typeof activePage | undefined = activePage;
|
<span>{crumb.title || "无标题"}</span>
|
||||||
while (current) {
|
</button>
|
||||||
breadcrumbs.unshift(current);
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activePage.icon && (
|
{activePage.icon && <span className="select-none text-3xl">{activePage.icon}</span>}
|
||||||
<span className="text-3xl select-none animate-in fade-in zoom-in-75 duration-300">
|
|
||||||
{activePage.icon}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
value={activePage.title}
|
value={activePage.title}
|
||||||
onChange={(e) => updatePage(activePage.id, { title: e.target.value })}
|
onChange={(e) => updatePage(activePage.id, { title: e.target.value })}
|
||||||
placeholder="无标题"
|
placeholder="无标题"
|
||||||
disabled={activePage.isLocked}
|
disabled={activePage.isLocked}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-3xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/20 text-foreground transition-colors",
|
"w-full border-none bg-transparent text-3xl font-bold text-foreground outline-none placeholder:text-muted-foreground/30",
|
||||||
activePage.isLocked && "opacity-80 cursor-not-allowed select-none"
|
activePage.isLocked && "cursor-not-allowed select-none opacity-80"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tags Section */}
|
<div className="ui-enter-delayed mb-6 flex flex-wrap items-center gap-2">
|
||||||
<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) => {
|
{activePage.tags?.map((tag) => {
|
||||||
const colors = getTagColor(tag);
|
const colors = getTagColor(tag);
|
||||||
return (
|
return (
|
||||||
@@ -119,151 +120,127 @@ export default function Home() {
|
|||||||
key={tag}
|
key={tag}
|
||||||
onClick={() => openSearchWithTag(tag)}
|
onClick={() => openSearchWithTag(tag)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 px-2.5 py-1 rounded-[3px] text-[11px] font-medium transition-colors border shadow-sm",
|
"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
|
colors.bg,
|
||||||
)}>
|
colors.text,
|
||||||
|
colors.border
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Hash size={10} className="opacity-70" />
|
<Hash size={10} className="opacity-70" />
|
||||||
{tag}
|
{tag}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newTags = activePage.tags?.filter(t => t !== tag) || [];
|
const newTags = activePage.tags?.filter((t) => t !== tag) || [];
|
||||||
updatePage(activePage.id, { tags: newTags });
|
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} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="relative">
|
|
||||||
{isAddingTag ? (
|
{isAddingTag ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={addTag}
|
||||||
if (tagInput.trim()) {
|
onKeyDown={(e) => {
|
||||||
const newTags = [...(activePage.tags || []), tagInput.trim()];
|
if (e.key === "Enter") {
|
||||||
updatePage(activePage.id, { tags: Array.from(new Set(newTags)) });
|
e.preventDefault();
|
||||||
}
|
addTag();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
setTagInput("");
|
setTagInput("");
|
||||||
setIsAddingTag(false);
|
setIsAddingTag(false);
|
||||||
}}
|
}
|
||||||
onKeyDown={(e) => {
|
}}
|
||||||
if (e.key === 'Enter') {
|
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"
|
||||||
e.preventDefault();
|
placeholder="输入标签"
|
||||||
if (tagInput.trim()) {
|
/>
|
||||||
const newTags = [...(activePage.tags || []), tagInput.trim()];
|
) : (
|
||||||
updatePage(activePage.id, { tags: Array.from(new Set(newTags)) });
|
<button
|
||||||
}
|
onClick={() => setIsAddingTag(true)}
|
||||||
setTagInput("");
|
className="ui-btn-secondary px-2 py-1 text-xs text-muted-foreground"
|
||||||
setIsAddingTag(false);
|
>
|
||||||
}
|
<Plus size={12} /> 添加标签
|
||||||
if (e.key === 'Escape') {
|
</button>
|
||||||
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">
|
||||||
<div className="relative group/icon-picker">
|
<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
|
<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} />
|
<X size={14} />
|
||||||
{activePage.icon ? '更改图标' : '添加图标'}
|
|
||||||
</button>
|
</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>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Editor Area or Folder Placeholder */}
|
{activePage.type === "folder" ? (
|
||||||
{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="flex flex-col items-center justify-center h-[50vh] text-muted-foreground animate-in fade-in duration-500">
|
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
|
||||||
{/* ... folder icon ... */}
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-h-[60vh] pb-24">
|
<div className="min-h-[60vh] pb-24">
|
||||||
@@ -280,17 +257,18 @@ export default function Home() {
|
|||||||
</div>
|
</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="ui-enter flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||||
<div className="w-16 h-16 bg-muted/50 rounded-2xl flex items-center justify-center">
|
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
|
||||||
<span className="text-4xl">👋</span>
|
<span className="text-4xl">🧠</span>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AIChatPanel editor={editor} isOpen={isChatOpen} onClose={() => setIsChatOpen(false)} />
|
<AIChatPanel editor={editor} isOpen={isChatOpen} onClose={() => setIsChatOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
</ImportProvider>
|
</ImportProvider>
|
||||||
|
|||||||
+20
-20
@@ -66,12 +66,12 @@ export default function SettingsPage() {
|
|||||||
<ResizableSidebar />
|
<ResizableSidebar />
|
||||||
<main className="flex-1 h-full overflow-y-auto p-4 md:p-10">
|
<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">
|
<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>
|
<h1 className="text-3xl font-bold tracking-tight">设置</h1>
|
||||||
<p className="text-muted-foreground">管理外观、AI、安全与备份。</p>
|
<p className="text-muted-foreground">管理外观、AI、安全与备份。</p>
|
||||||
</header>
|
</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">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<Type size={18} className="text-primary" />
|
<Type size={18} className="text-primary" />
|
||||||
<h2 className="text-xl font-semibold">外观设置</h2>
|
<h2 className="text-xl font-semibold">外观设置</h2>
|
||||||
@@ -83,7 +83,7 @@ export default function SettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
onChange={(e) => setFontFamily(e.target.value)}
|
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) => (
|
{fontOptions.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
@@ -102,7 +102,7 @@ export default function SettingsPage() {
|
|||||||
step="1"
|
step="1"
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
|
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
|
||||||
className="w-full"
|
className="w-full accent-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export default function SettingsPage() {
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={lineHeight}
|
value={lineHeight}
|
||||||
onChange={(e) => setLineHeight(parseFloat(e.target.value))}
|
onChange={(e) => setLineHeight(parseFloat(e.target.value))}
|
||||||
className="w-full"
|
className="w-full accent-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ export default function SettingsPage() {
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={tableLineHeight}
|
value={tableLineHeight}
|
||||||
onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
|
onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
|
||||||
className="w-full"
|
className="w-full accent-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +138,7 @@ export default function SettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={timezone}
|
value={timezone}
|
||||||
onChange={(e) => setTimezone(e.target.value)}
|
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) => (
|
{timezoneOptions.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
@@ -149,7 +149,7 @@ export default function SettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</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">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<Sparkles size={18} className="text-primary" />
|
<Sparkles size={18} className="text-primary" />
|
||||||
<h2 className="text-xl font-semibold">AI 配置</h2>
|
<h2 className="text-xl font-semibold">AI 配置</h2>
|
||||||
@@ -162,7 +162,7 @@ export default function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={aiConfig.baseURL}
|
value={aiConfig.baseURL}
|
||||||
onChange={(e) => setAIConfig({ baseURL: e.target.value })}
|
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"
|
placeholder="https://api.openai.com/v1"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -173,7 +173,7 @@ export default function SettingsPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={aiConfig.apiKey}
|
value={aiConfig.apiKey}
|
||||||
onChange={(e) => setAIConfig({ apiKey: e.target.value })}
|
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-..."
|
placeholder="sk-..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -184,18 +184,18 @@ export default function SettingsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={aiConfig.model}
|
value={aiConfig.model}
|
||||||
onChange={(e) => setAIConfig({ model: e.target.value })}
|
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"
|
placeholder="gpt-4o-mini"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-card p-6">
|
<section className="ui-card ui-enter-delayed p-6">
|
||||||
<PromptManagement />
|
<PromptManagement />
|
||||||
</section>
|
</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">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<Lock size={18} className="text-primary" />
|
<Lock size={18} className="text-primary" />
|
||||||
<h2 className="text-xl font-semibold">安全设置</h2>
|
<h2 className="text-xl font-semibold">安全设置</h2>
|
||||||
@@ -206,7 +206,7 @@ export default function SettingsPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
className="ui-input"
|
||||||
placeholder="当前密码"
|
placeholder="当前密码"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -214,7 +214,7 @@ export default function SettingsPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
className="ui-input"
|
||||||
placeholder="新密码"
|
placeholder="新密码"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -222,7 +222,7 @@ export default function SettingsPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
className="ui-input"
|
||||||
placeholder="确认新密码"
|
placeholder="确认新密码"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -235,7 +235,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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} />
|
<Save size={14} />
|
||||||
保存密码
|
保存密码
|
||||||
@@ -243,7 +243,7 @@ export default function SettingsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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>
|
<h2 className="text-xl font-semibold">备份与恢复</h2>
|
||||||
<p className="text-sm text-muted-foreground">恢复会覆盖当前全部文档,请谨慎操作。</p>
|
<p className="text-sm text-muted-foreground">恢复会覆盖当前全部文档,请谨慎操作。</p>
|
||||||
|
|
||||||
@@ -254,13 +254,13 @@ export default function SettingsPage() {
|
|||||||
const { exportAllPagesAsZip } = await import("@/lib/export");
|
const { exportAllPagesAsZip } = await import("@/lib/export");
|
||||||
await exportAllPagesAsZip(pages);
|
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} />
|
<Download size={14} />
|
||||||
下载备份 (Zip)
|
下载备份 (Zip)
|
||||||
</button>
|
</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} />
|
<Upload size={14} />
|
||||||
上传备份并恢复
|
上传备份并恢复
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Sparkles, Wand2 } from "lucide-react";
|
import { Sparkles, Wand2 } from "lucide-react";
|
||||||
@@ -15,41 +15,38 @@ export function AIAssist({ onSuggest, isOpen, onClose }: AIAssistProps) {
|
|||||||
const { prompts } = useSettingsStore();
|
const { prompts } = useSettingsStore();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
// Check if we are in browser to avoid CSR mismatches, though useClient handles most.
|
|
||||||
if (typeof document === 'undefined') return null;
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/10 backdrop-blur-[1px]" onClick={onClose}>
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/25 backdrop-blur-[2px]" onClick={onClose}>
|
||||||
<div
|
<div className="ui-card w-[320px] overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
className="bg-popover border rounded-lg shadow-xl w-72 overflow-hidden animate-in zoom-in-95 duration-200"
|
<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">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Sparkles size={16} className="text-primary" />
|
||||||
>
|
AI 写作助手
|
||||||
<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>
|
</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) => (
|
{prompts.map((prompt) => (
|
||||||
<button
|
<button
|
||||||
key={prompt.id}
|
key={prompt.id}
|
||||||
onClick={() => onSuggest(prompt)}
|
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">
|
<div className="flex items-start gap-3">
|
||||||
<Wand2 size={16} className="text-primary" />
|
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||||
</div>
|
<Wand2 size={16} className="text-primary" />
|
||||||
<div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">{prompt.label}</div>
|
<div>
|
||||||
<div className="text-[11px] text-muted-foreground">{prompt.description}</div>
|
<div className="text-sm font-medium text-foreground">{prompt.label}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{prompt.description}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 bg-muted/20 border-t">
|
<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 text-[10px] text-muted-foreground px-1">
|
<div className="flex items-center justify-between">
|
||||||
<span>AI Powered</span>
|
<span>AI Powered</span>
|
||||||
<span className="opacity-50">ESC to close</span>
|
<span className="opacity-70">按 ESC 关闭</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Send, User, Bot, Copy, FileText, X, Sparkles, Eraser } from "lucide-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 [input, setInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [useContext, setUseContext] = useState(true);
|
const [useContext, setUseContext] = useState(true);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [tokenCount, setTokenCount] = useState(0);
|
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(() => {
|
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));
|
setTokenCount(Math.ceil(text.length * 0.7));
|
||||||
}, [messages, input]);
|
}, [messages, input]);
|
||||||
|
|
||||||
// Scroll to bottom effect
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const handleStop = () => {
|
const handleStop = () => {
|
||||||
if (abortControllerRef.current) {
|
if (abortControllerRef.current) {
|
||||||
abortControllerRef.current.abort();
|
abortControllerRef.current.abort();
|
||||||
@@ -59,35 +50,35 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
|
|||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() || isLoading) return;
|
if (!input.trim() || isLoading) return;
|
||||||
if (!aiConfig.apiKey) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMsg: Message = { id: Date.now().toString(), role: "user", content: input };
|
const userMsg: Message = { id: Date.now().toString(), role: "user", content: input };
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
setInput("");
|
setInput("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
const contextMsg: Message | null = (useContext && editor)
|
const contextMsg: Message | null =
|
||||||
? { id: "context", role: "system", content: `Current Document Context:\n${editor.getText().slice(0, 4000)}...` }
|
useContext && editor
|
||||||
: null;
|
? { id: "context", role: "system", content: `当前文档上下文:\n${editor.getText().slice(0, 4000)}...` }
|
||||||
|
: null;
|
||||||
|
|
||||||
const apiMessages = [
|
const apiMessages = [...(contextMsg ? [contextMsg] : []), ...messages.filter((m) => m.role !== "system"), userMsg];
|
||||||
...(contextMsg ? [contextMsg] : []),
|
|
||||||
...messages.filter(m => m.role !== "system"),
|
|
||||||
userMsg
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/ai/chat", {
|
const res = await fetch("/api/ai/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: apiMessages.map(m => ({ role: m.role, content: m.content })),
|
messages: apiMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
config: aiConfig
|
config: aiConfig,
|
||||||
}),
|
}),
|
||||||
signal: abortControllerRef.current.signal
|
signal: abortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(res.statusText);
|
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 reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const assistantMsg: Message = { id: (Date.now() + 1).toString(), role: "assistant", content: "" };
|
const assistantMsg: Message = { id: (Date.now() + 1).toString(), role: "assistant", content: "" };
|
||||||
|
setMessages((prev) => [...prev, assistantMsg]);
|
||||||
setMessages(prev => [...prev, assistantMsg]);
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -107,28 +97,34 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
|
|||||||
const lines = chunk.split("\n\n");
|
const lines = chunk.split("\n\n");
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("data: ")) {
|
if (!line.startsWith("data: ")) continue;
|
||||||
const data = line.slice(6);
|
const data = line.slice(6);
|
||||||
if (data === "[DONE]") break;
|
if (data === "[DONE]") break;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
const content = parsed.choices[0]?.delta?.content || "";
|
const content = parsed.choices?.[0]?.delta?.content || "";
|
||||||
if (content) {
|
if (content) {
|
||||||
assistantMsg.content += content;
|
assistantMsg.content += content;
|
||||||
setMessages(prev => prev.map(m => m.id === assistantMsg.id ? { ...assistantMsg } : m));
|
setMessages((prev) => prev.map((m) => (m.id === assistantMsg.id ? { ...assistantMsg } : m)));
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Parse error", e);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Parse error", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const isAbortError = e instanceof Error && e.name === "AbortError";
|
const isAbortError = e instanceof Error && e.name === "AbortError";
|
||||||
if (isAbortError) {
|
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 {
|
} 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -145,70 +141,68 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between border-b border-border/70 p-4">
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="text-primary w-5 h-5" />
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-lg leading-none">AI 助手</h2>
|
<h2 className="text-lg font-semibold leading-none">AI 助手</h2>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5 font-mono opacity-80">
|
<p className="mt-0.5 font-mono text-[10px] text-muted-foreground opacity-80">{aiConfig.model || "gpt-3.5-turbo"}</p>
|
||||||
{aiConfig.model || "gpt-3.5-turbo"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground mr-2">Est. Tokens: {tokenCount}</span>
|
<span className="mr-1 text-xs text-muted-foreground">估算 Tokens: {tokenCount}</span>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-muted rounded-md transition-colors">
|
<button onClick={onClose} className="ui-icon-btn h-8 w-8">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="text-center text-muted-foreground mt-20">
|
<div className="mt-20 text-center text-muted-foreground">
|
||||||
<p>有什么我可以帮你的吗?</p>
|
<p>有什么我可以帮你的吗?</p>
|
||||||
<p className="text-sm mt-2">我可以协助写作、润色、摘要或回答问题。</p>
|
<p className="mt-2 text-sm">我可以帮你写作、润色、总结或回答问题。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className={cn("flex gap-3", msg.role === "user" ? "flex-row-reverse" : "")}>
|
<div key={msg.id} className={cn("flex gap-3", msg.role === "user" ? "flex-row-reverse" : "")}>
|
||||||
<div className={cn(
|
<div
|
||||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
className={cn(
|
||||||
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
"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} />}
|
{msg.role === "user" ? <User size={16} /> : <Bot size={16} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div
|
||||||
"group relative max-w-[85%] rounded-lg p-3 text-sm",
|
className={cn(
|
||||||
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
|
"group relative max-w-[85%] rounded-lg p-3 text-sm",
|
||||||
msg.role === "system" && "bg-destructive/10 text-destructive w-full max-w-full"
|
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"
|
||||||
{/* Markdown Rendering for Assistant */}
|
)}
|
||||||
|
>
|
||||||
{msg.role === "assistant" ? (
|
{msg.role === "assistant" ? (
|
||||||
<div
|
<div
|
||||||
className="prose dark:prose-invert prose-sm max-w-none break-words [&>p]:mb-2 [&>ul]:list-disc [&>ul]:pl-4 [&>ol]:list-decimal [&>ol]:pl-4"
|
className="prose 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) }}
|
dangerouslySetInnerHTML={{ __html: sanitizeHtml(marked.parse(msg.content) as string) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Assistant Actions */}
|
|
||||||
{msg.role === "assistant" && !isLoading && (
|
{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
|
<button
|
||||||
onClick={() => handleInsert(msg.content)}
|
onClick={() => handleInsert(msg.content)}
|
||||||
className="p-1 text-xs bg-background border rounded shadow hover:bg-muted flex items-center gap-1"
|
className="flex items-center gap-1 rounded border bg-background px-1 py-1 text-xs shadow hover:bg-muted"
|
||||||
title="插入到光标位置"
|
title="插入到编辑器"
|
||||||
>
|
>
|
||||||
<FileText size={12} /> 插入
|
<FileText size={12} /> 插入
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(msg.content)}
|
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="复制"
|
title="复制"
|
||||||
>
|
>
|
||||||
<Copy size={12} />
|
<Copy size={12} />
|
||||||
@@ -221,21 +215,20 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input Area */}
|
<div className="border-t border-border/70 bg-muted/20 p-4">
|
||||||
<div className="p-4 border-t bg-muted/20">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useContext}
|
checked={useContext}
|
||||||
onChange={(e) => setUseContext(e.target.checked)}
|
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>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMessages([])}
|
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="清空对话"
|
title="清空对话"
|
||||||
>
|
>
|
||||||
<Eraser size={12} /> 清空
|
<Eraser size={12} /> 清空
|
||||||
@@ -252,18 +245,18 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入消息..."
|
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
|
<button
|
||||||
onClick={isLoading ? handleStop : handleSend}
|
onClick={isLoading ? handleStop : handleSend}
|
||||||
disabled={!input.trim() && !isLoading}
|
disabled={!input.trim() && !isLoading}
|
||||||
className={cn(
|
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"
|
isLoading ? "bg-red-500 text-white" : "bg-primary text-primary-foreground"
|
||||||
)}
|
)}
|
||||||
title={isLoading ? "停止生成" : "发送"}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
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 [request, setRequest] = React.useState<ConfirmRequest | null>(null);
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
const closeWith = React.useCallback((value: boolean) => {
|
const closeWith = React.useCallback(
|
||||||
if (request) {
|
(value: boolean) => {
|
||||||
request.resolve(value);
|
if (request) {
|
||||||
setRequest(null);
|
request.resolve(value);
|
||||||
}
|
setRequest(null);
|
||||||
setOpen(false);
|
}
|
||||||
}, [request]);
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
const confirm = React.useCallback<ConfirmFn>((options) => {
|
const confirm = React.useCallback<ConfirmFn>((options) => {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
@@ -58,11 +61,9 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Portal>
|
<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.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 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.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">
|
<Dialog.Title className="text-xl font-semibold tracking-tight text-foreground">{request?.title}</Dialog.Title>
|
||||||
{request?.title}
|
|
||||||
</Dialog.Title>
|
|
||||||
{request?.description && (
|
{request?.description && (
|
||||||
<Dialog.Description className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
<Dialog.Description className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||||
{request.description}
|
{request.description}
|
||||||
@@ -70,21 +71,13 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
<button
|
<button type="button" onClick={() => closeWith(false)} className="ui-btn-secondary">
|
||||||
type="button"
|
|
||||||
onClick={() => closeWith(false)}
|
|
||||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
{request?.cancelText || "取消"}
|
{request?.cancelText || "取消"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => closeWith(true)}
|
onClick={() => closeWith(true)}
|
||||||
className={
|
className={request?.tone === "danger" ? "ui-btn-danger" : "ui-btn-primary"}
|
||||||
request?.tone === "danger"
|
|
||||||
? "rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-opacity hover:opacity-90"
|
|
||||||
: "rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{request?.confirmText || "确认"}
|
{request?.confirmText || "确认"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchStore } from "@/lib/search-store";
|
import { useSearchStore } from "@/lib/search-store";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -9,6 +9,7 @@ import { Search, FileText, Folder } from "lucide-react";
|
|||||||
export function SearchCommand() {
|
export function SearchCommand() {
|
||||||
const { isOpen, setOpen, query, setQuery } = useSearchStore();
|
const { isOpen, setOpen, query, setQuery } = useSearchStore();
|
||||||
const { pages, setActivePageId } = useEditorStore();
|
const { pages, setActivePageId } = useEditorStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -29,34 +30,30 @@ export function SearchCommand() {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
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 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
<div
|
<div className="fixed inset-0" onClick={() => setOpen(false)} />
|
||||||
className="fixed inset-0"
|
<div className="ui-card relative w-full max-w-lg overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
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">
|
|
||||||
<Command className="w-full">
|
<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" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<Command.Input
|
<Command.Input
|
||||||
placeholder="搜索文档..."
|
placeholder="搜索文档或标签..."
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
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>
|
</div>
|
||||||
<Command.List className="max-h-[300px] overflow-y-auto p-2">
|
<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.Empty className="py-6 text-center text-sm text-muted-foreground">未找到匹配结果</Command.Empty>
|
||||||
|
|
||||||
<Command.Group heading="文档">
|
<Command.Group heading="文档">
|
||||||
{pages.map((page) => (
|
{pages.map((page) => (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={page.id}
|
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))}
|
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>
|
<span>{page.title || "无标题"}</span>
|
||||||
</Command.Item>
|
</Command.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus, Trash2, Edit2, X, Sparkles } from "lucide-react";
|
import { Plus, Trash2, Edit2, X, Sparkles } from "lucide-react";
|
||||||
import { useSettingsStore, AIPrompt } from "@/lib/settings-store";
|
import { useSettingsStore, AIPrompt } from "@/lib/settings-store";
|
||||||
|
import { useConfirm } from "@/components/confirm-provider";
|
||||||
|
|
||||||
export function PromptManagement() {
|
export function PromptManagement() {
|
||||||
|
const confirm = useConfirm();
|
||||||
const { prompts, addPrompt, updatePrompt, deletePrompt, resetPrompts } = useSettingsStore();
|
const { prompts, addPrompt, updatePrompt, deletePrompt, resetPrompts } = useSettingsStore();
|
||||||
const [editing, setEditing] = useState<AIPrompt | null>(null);
|
const [editing, setEditing] = useState<AIPrompt | null>(null);
|
||||||
const [isNew, setIsNew] = useState(false);
|
const [isNew, setIsNew] = useState(false);
|
||||||
@@ -12,10 +14,17 @@ export function PromptManagement() {
|
|||||||
const handleSave = (e: React.FormEvent) => {
|
const handleSave = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editing) return;
|
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) {
|
if (isNew) {
|
||||||
addPrompt(editing);
|
addPrompt(payload);
|
||||||
} else {
|
} else {
|
||||||
updatePrompt(editing.id, editing);
|
updatePrompt(payload.id, payload);
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setIsNew(false);
|
setIsNew(false);
|
||||||
@@ -25,140 +34,137 @@ export function PromptManagement() {
|
|||||||
setEditing({
|
setEditing({
|
||||||
id: `custom_${Date.now()}`,
|
id: `custom_${Date.now()}`,
|
||||||
label: "新功能",
|
label: "新功能",
|
||||||
description: "描述...",
|
description: "功能描述...",
|
||||||
systemPrompt: "你是...",
|
systemPrompt: "你是一个专业助手,请根据用户输入完成任务。",
|
||||||
});
|
});
|
||||||
setIsNew(true);
|
setIsNew(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-5">
|
||||||
<div className="flex items-center justify-between gap-2 pb-2 border-b">
|
<div className="flex items-center justify-between gap-2 border-b border-border/70 pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles size={20} className="text-primary" />
|
<Sparkles size={20} className="text-primary" />
|
||||||
<h2 className="text-xl font-semibold">自定义 AI 提示词</h2>
|
<h2 className="text-xl font-semibold">自定义 AI 提示词</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button onClick={startNew} className="ui-btn-primary px-3 py-1.5 text-xs">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
添加新功能
|
添加提示词
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={resetPrompts} className="ui-btn-secondary px-3 py-1.5 text-xs">
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
{prompts.map((prompt) => (
|
||||||
{prompts.map((prompt) => (
|
<div key={prompt.id} className="rounded-xl border border-border/70 bg-muted/25 p-3">
|
||||||
<div key={prompt.id} className="flex items-start justify-between p-3 bg-muted/30 border rounded-lg group">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<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}
|
{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}
|
{prompt.id}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{prompt.description}</p>
|
||||||
{prompt.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(prompt);
|
setEditing(prompt);
|
||||||
setIsNew(false);
|
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} />
|
<Edit2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deletePrompt(prompt.id)}
|
onClick={async () => {
|
||||||
className="p-1.5 hover:bg-red-500/10 rounded text-muted-foreground hover:text-red-500"
|
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} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit/Create Dialog (Simple Overlay) */}
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
|
||||||
<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="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 pb-2 border-b">
|
<div className="flex items-center justify-between border-b border-border/60 pb-3">
|
||||||
<h3 className="font-semibold text-lg">{isNew ? "新建 AI 功能" : "编辑 AI 功能"}</h3>
|
<h3 className="text-lg font-semibold">{isNew ? "新建 AI 提示词" : "编辑 AI 提示词"}</h3>
|
||||||
<button onClick={() => setEditing(null)}>
|
<button onClick={() => setEditing(null)} className="ui-icon-btn h-8 w-8">
|
||||||
<X size={20} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<label className="space-y-2 text-sm">
|
||||||
<label className="text-sm font-medium">名称 (Label)</label>
|
<span className="font-medium">名称</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editing.label}
|
value={editing.label}
|
||||||
onChange={e => setEditing({ ...editing!, label: e.target.value })}
|
onChange={(e) => setEditing({ ...editing, label: e.target.value })}
|
||||||
className="w-full p-2 bg-muted/50 border rounded text-sm"
|
className="ui-input"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="space-y-2">
|
<label className="space-y-2 text-sm">
|
||||||
<label className="text-sm font-medium">唯一标识 (ID)</label>
|
<span className="font-medium">唯一标识</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editing.id}
|
value={editing.id}
|
||||||
onChange={e => setEditing({ ...editing!, id: e.target.value })}
|
onChange={(e) => setEditing({ ...editing, id: e.target.value })}
|
||||||
className="w-full p-2 bg-muted/50 border rounded text-sm"
|
className="ui-input"
|
||||||
readOnly={!isNew}
|
readOnly={!isNew}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editing.description}
|
value={editing.description}
|
||||||
onChange={e => setEditing({ ...editing!, description: e.target.value })}
|
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||||
className="w-full p-2 bg-muted/50 border rounded text-sm"
|
className="ui-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">系统提示词 (System Prompt)</label>
|
<label className="space-y-2 text-sm block">
|
||||||
|
<span className="font-medium">系统提示词</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={editing.systemPrompt}
|
value={editing.systemPrompt}
|
||||||
onChange={e => setEditing({ ...editing!, systemPrompt: e.target.value })}
|
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"
|
className="ui-input min-h-32 font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">这段内容会发送给模型,用于定义角色和回答风格。</p>
|
||||||
这是发送给 AI 的指令,定义了它的角色和行为。
|
</label>
|
||||||
</p>
|
|
||||||
</div>
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<button type="button" onClick={() => setEditing(null)} className="ui-btn-secondary">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditing(null)}
|
|
||||||
className="px-4 py-2 text-sm font-medium bg-muted hover:bg-muted/80 rounded"
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit" className="ui-btn-primary">
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded"
|
|
||||||
>
|
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+124
-129
@@ -1,8 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useRef, useEffect, useState } from "react";
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
import { useEditorStore, Page } from "@/lib/store";
|
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 { useTheme } from "next-themes";
|
||||||
import { TreeView } from "./sidebar/tree-view";
|
import { TreeView } from "./sidebar/tree-view";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -11,20 +30,29 @@ import { SearchCommand } from "@/components/search-command";
|
|||||||
import { cn, getTagColor } from "@/lib/utils";
|
import { cn, getTagColor } from "@/lib/utils";
|
||||||
import { useSearchStore } from "@/lib/search-store";
|
import { useSearchStore } from "@/lib/search-store";
|
||||||
import { useImport } from "@/components/import-context";
|
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";
|
import { arrayMove } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
function RootDropZone() {
|
function RootDropZone() {
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({ id: "root-drop-zone" });
|
||||||
id: 'root-drop-zone',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
isOver && "bg-accent/30 border-primary/20 text-primary/70"
|
"hover:text-muted-foreground/50",
|
||||||
|
isOver && "border-primary/20 bg-accent/30 text-primary/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isOver ? "移动到根目录" : ""}
|
{isOver ? "移动到根目录" : ""}
|
||||||
@@ -33,7 +61,6 @@ function RootDropZone() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ResizableSidebar() {
|
export function ResizableSidebar() {
|
||||||
// State
|
|
||||||
const [width, setWidth] = useState(() => {
|
const [width, setWidth] = useState(() => {
|
||||||
if (typeof window === "undefined") return 256;
|
if (typeof window === "undefined") return 256;
|
||||||
const savedWidth = localStorage.getItem("sidebar-width");
|
const savedWidth = localStorage.getItem("sidebar-width");
|
||||||
@@ -46,13 +73,11 @@ export function ResizableSidebar() {
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef<HTMLElement>(null);
|
const sidebarRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
// Save state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('sidebar-width', width.toString());
|
localStorage.setItem("sidebar-width", width.toString());
|
||||||
localStorage.setItem('sidebar-collapsed', isCollapsed.toString());
|
localStorage.setItem("sidebar-collapsed", isCollapsed.toString());
|
||||||
}, [width, isCollapsed]);
|
}, [width, isCollapsed]);
|
||||||
|
|
||||||
// Resizing Logic
|
|
||||||
const startResizing = React.useCallback(() => {
|
const startResizing = React.useCallback(() => {
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -61,14 +86,16 @@ export function ResizableSidebar() {
|
|||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resize = React.useCallback((mouseMoveEvent: MouseEvent) => {
|
const resize = React.useCallback(
|
||||||
if (isResizing) {
|
(mouseMoveEvent: MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
const newWidth = mouseMoveEvent.clientX;
|
const newWidth = mouseMoveEvent.clientX;
|
||||||
if (newWidth >= 200 && newWidth <= 480) {
|
if (newWidth >= 220 && newWidth <= 480) {
|
||||||
setWidth(newWidth);
|
setWidth(newWidth);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, [isResizing]);
|
[isResizing]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("mousemove", resize);
|
window.addEventListener("mousemove", resize);
|
||||||
@@ -83,32 +110,30 @@ export function ResizableSidebar() {
|
|||||||
<aside
|
<aside
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={cn(
|
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",
|
isResizing ? "transition-none" : "transition-[width] duration-300 ease-in-out",
|
||||||
isCollapsed && "w-[0px] border-none"
|
isCollapsed && "w-[0px] border-none"
|
||||||
)}
|
)}
|
||||||
style={{ width: isCollapsed ? 0 : width }}
|
style={{ width: isCollapsed ? 0 : width }}
|
||||||
>
|
>
|
||||||
{/* Collapse Toggle (Desktop only) */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className={cn(
|
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",
|
"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 && "opacity-100 -right-8" // Visible when collapsed
|
isCollapsed && "-right-8 opacity-100"
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||||
>
|
>
|
||||||
{isCollapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />}
|
{isCollapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />}
|
||||||
</button>
|
</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 />
|
<SidebarContent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resize Handle */}
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div
|
<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}
|
onMouseDown={startResizing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -122,28 +147,30 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
const { triggerImport, isImporting } = useImport();
|
const { triggerImport, isImporting } = useImport();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
// Collapsible State
|
|
||||||
const [isWorkspaceOpen, setIsWorkspaceOpen] = React.useState(true);
|
const [isWorkspaceOpen, setIsWorkspaceOpen] = React.useState(true);
|
||||||
const [isTagsOpen, setIsTagsOpen] = React.useState(true);
|
const [isTagsOpen, setIsTagsOpen] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
router.push('/login');
|
router.push("/login");
|
||||||
router.refresh(); // Refresh to ensure middleware runs
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Logout failed", error);
|
console.error("Logout failed", error);
|
||||||
// Fallback redirect
|
router.push("/login");
|
||||||
router.push('/login');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// DnD Sensors
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
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 activeId = active.id as string;
|
||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
||||||
if (activeId === overId) return;
|
if (activeId === overId) return;
|
||||||
|
|
||||||
// Check if moving to root
|
if (overId === "root-drop-zone") {
|
||||||
if (overId === 'root-drop-zone') {
|
|
||||||
movePage(activeId, null);
|
movePage(activeId, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check relationship: Reparenting vs Reordering
|
const activeNode = pages.find((p) => p.id === activeId);
|
||||||
const activeNode = pages.find(p => p.id === activeId);
|
const overNode = pages.find((p) => p.id === overId);
|
||||||
const overNode = pages.find(p => p.id === overId);
|
|
||||||
|
|
||||||
if (!activeNode || !overNode) return;
|
if (!activeNode || !overNode) return;
|
||||||
|
|
||||||
// If 'over' is a folder and logic dictates "drop INTO", do move
|
if (activeNode.type === "file" && overNode.type === "folder" && activeNode.parentId !== overNode.id) {
|
||||||
// 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) {
|
|
||||||
movePage(activeId, overId);
|
movePage(activeId, overId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSameParent = activeNode.parentId === overNode.parentId;
|
const isSameParent = activeNode.parentId === overNode.parentId;
|
||||||
|
|
||||||
if (isSameParent) {
|
if (isSameParent) {
|
||||||
// Reordering
|
const siblings = pages
|
||||||
// Get all siblings
|
.filter((p) => p.parentId === activeNode.parentId)
|
||||||
const siblings = pages.filter(p => p.parentId === activeNode.parentId).sort((a, b) => (a.order || 0) - (b.order || 0));
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
const oldIndex = siblings.findIndex(p => p.id === activeId);
|
const oldIndex = siblings.findIndex((p) => p.id === activeId);
|
||||||
const newIndex = siblings.findIndex(p => p.id === overId);
|
const newIndex = siblings.findIndex((p) => p.id === overId);
|
||||||
|
|
||||||
if (oldIndex !== newIndex) {
|
if (oldIndex !== newIndex) {
|
||||||
const newOrder = arrayMove(siblings, oldIndex, newIndex);
|
const newOrder = arrayMove(siblings, oldIndex, newIndex);
|
||||||
// Assign new order values
|
|
||||||
const updates = newOrder.map((p, index) => ({ id: p.id, order: index }));
|
const updates = newOrder.map((p, index) => ({ id: p.id, order: index }));
|
||||||
reorderPages(updates);
|
reorderPages(updates);
|
||||||
}
|
}
|
||||||
|
} else if (overNode.type === "folder") {
|
||||||
|
movePage(activeId, overId);
|
||||||
} else {
|
} else {
|
||||||
// Reparenting
|
movePage(activeId, overNode.parentId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [activeDragItem, setActiveDragItem] = React.useState<Page | null>(null);
|
const [activeDragItem, setActiveDragItem] = React.useState<Page | null>(null);
|
||||||
|
|
||||||
const onDragStart = (event: DragStartEvent) => {
|
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);
|
if (item) setActiveDragItem(item);
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPages();
|
fetchPages();
|
||||||
}, [fetchPages]);
|
}, [fetchPages]);
|
||||||
|
|
||||||
// Handle clicks that should close mobile sidebar
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (onCloseMobile) onCloseMobile();
|
if (onCloseMobile) onCloseMobile();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-secondary">
|
<div className="flex h-full flex-col bg-secondary">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between p-4">
|
||||||
<div className="p-4 flex items-center justify-between">
|
<div className="flex cursor-pointer items-center gap-2 font-semibold text-foreground/80 transition-colors hover:text-foreground">
|
||||||
<div className="flex items-center gap-2 font-semibold text-foreground/80 hover:text-foreground transition-colors cursor-pointer">
|
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-[10px] text-primary-foreground shadow-sm">AI</div>
|
||||||
<div className="w-6 h-6 bg-primary rounded-md flex items-center justify-center text-primary-foreground text-[10px] shadow-sm">AI</div>
|
|
||||||
<span className="tracking-tight">NoteAI</span>
|
<span className="tracking-tight">NoteAI</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 transition-opacity">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => addPage(null, 'file')}
|
onClick={() => addPage(null, "file")}
|
||||||
className="p-1.5 hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-all hover:text-foreground"
|
className="ui-icon-btn h-8 w-8"
|
||||||
title="新建文件"
|
title="新建文件"
|
||||||
>
|
>
|
||||||
<FilePlus size={16} />
|
<FilePlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => addPage(null, 'folder')}
|
onClick={() => addPage(null, "folder")}
|
||||||
className="p-1.5 hover:bg-muted-foreground/10 rounded-md text-muted-foreground transition-all hover:text-foreground"
|
className="ui-icon-btn h-8 w-8"
|
||||||
title="新建文件夹"
|
title="新建文件夹"
|
||||||
>
|
>
|
||||||
<FolderPlus size={16} />
|
<FolderPlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
{/* Mobile Close Button (Added to layout properly) */}
|
|
||||||
{onCloseMobile && (
|
{onCloseMobile && (
|
||||||
<button
|
<button
|
||||||
onClick={onCloseMobile}
|
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="关闭菜单"
|
title="关闭菜单"
|
||||||
>
|
>
|
||||||
<PanelLeftClose size={16} />
|
<PanelLeftClose size={16} />
|
||||||
@@ -262,30 +265,27 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Actions */}
|
<div className="mb-4 space-y-1 px-3">
|
||||||
<div className="px-3 space-y-1 mb-4">
|
|
||||||
<SearchCommand />
|
<SearchCommand />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(true);
|
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" />
|
<Search size={16} className="group-hover:text-foreground" />
|
||||||
<span className="group-hover:text-foreground">全局搜索</span>
|
<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">
|
<kbd className="ui-kbd ml-auto">
|
||||||
<span className="text-xs">⌘</span>K
|
<span className="text-xs">Ctrl</span>K
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pages List (Tree View) */}
|
<div className="flex-1 space-y-1 overflow-y-auto px-2">
|
||||||
{/* Pages List (Tree View) */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 space-y-1">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
|
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">
|
<span className="flex items-center gap-1.5">
|
||||||
{isWorkspaceOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
{isWorkspaceOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
@@ -294,10 +294,10 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
</span>
|
</span>
|
||||||
<Plus
|
<Plus
|
||||||
size={14}
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
addPage(null, 'file');
|
addPage(null, "file");
|
||||||
setIsWorkspaceOpen(true);
|
setIsWorkspaceOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -310,11 +310,8 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
) : pages.length === 0 ? (
|
) : pages.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
|
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||||
<p className="mb-2">暂无页面</p>
|
<p className="mb-2">暂无页面</p>
|
||||||
<button
|
<button onClick={() => addPage(null, "file")} className="text-primary hover:underline">
|
||||||
onClick={() => addPage(null, 'file')}
|
新建一个
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
新建一个?
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -324,19 +321,21 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
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} />
|
<TreeView pages={pages} parentId={null} />
|
||||||
<RootDropZone />
|
<RootDropZone />
|
||||||
</div>
|
</div>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeDragItem ? (
|
{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 ? (
|
{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>
|
<span className="flex h-4 w-4 items-center justify-center text-[16px] leading-none grayscale-[0.2]">
|
||||||
) : activeDragItem.type === 'folder' ? (
|
{activeDragItem.icon}
|
||||||
<Folder size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
|
</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}
|
{activeDragItem.title}
|
||||||
</div>
|
</div>
|
||||||
@@ -348,11 +347,10 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags List */}
|
<div className="border-t border-border/40 px-2 py-2">
|
||||||
<div className="px-2 py-2 border-t border-border/40">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTagsOpen(!isTagsOpen)}
|
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">
|
<span className="flex items-center gap-1.5">
|
||||||
{isTagsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
{isTagsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
@@ -363,7 +361,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
|
|
||||||
{isTagsOpen && (
|
{isTagsOpen && (
|
||||||
<div className="flex flex-wrap gap-1 px-2">
|
<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);
|
const colors = getTagColor(tag);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -373,55 +371,52 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
handleItemClick();
|
handleItemClick();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 px-2.5 py-1 rounded-[3px] text-[11px] font-medium transition-colors border shadow-sm",
|
"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,
|
colors.bg,
|
||||||
"hover:scale-105 active:scale-95 duration-200"
|
colors.text,
|
||||||
|
colors.border
|
||||||
)}
|
)}
|
||||||
title={`Filter by ${tag}`}
|
title={`按标签筛选:${tag}`}
|
||||||
>
|
>
|
||||||
<Hash size={12} className="opacity-70" />
|
<Hash size={12} className="opacity-70" />
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{pages.flatMap(p => p.tags || []).length === 0 && (
|
{pages.flatMap((p) => p.tags || []).length === 0 && (
|
||||||
<span className="text-[10px] text-muted-foreground/50 italic px-1">无标签</span>
|
<span className="px-1 text-[10px] italic text-muted-foreground/60">暂无标签</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
<div className="space-y-1 border-t bg-muted/20 p-3 backdrop-blur-sm">
|
||||||
<div className="p-3 border-t bg-muted/20 backdrop-blur-sm space-y-1">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => triggerImport(null)}
|
onClick={() => triggerImport(null)}
|
||||||
disabled={isImporting}
|
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} />
|
<Upload size={16} />
|
||||||
<span>{isImporting ? '导入中...' : '导入文档'}</span>
|
<span>{isImporting ? "导入中..." : "导入文档"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={handleItemClick}
|
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} />
|
<Settings size={16} />
|
||||||
<span>设置</span>
|
<span>设置</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
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"
|
className="ui-icon-btn"
|
||||||
title={theme === 'dark' ? '切换到明亮模式' : '切换到暗黑模式'}
|
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>
|
||||||
<button
|
<button onClick={handleLogout} className="ui-icon-btn hover:text-destructive" title="退出登录">
|
||||||
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="退出登录"
|
|
||||||
>
|
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ function TreeNode({
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Portal>
|
<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
|
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const targetParentId = node.type === "folder" ? node.id : node.parentId;
|
const targetParentId = node.type === "folder" ? node.id : node.parentId;
|
||||||
@@ -135,7 +135,7 @@ function TreeNode({
|
|||||||
<span>新建文件</span>
|
<span>新建文件</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const targetParentId = node.type === "folder" ? node.id : node.parentId;
|
const targetParentId = node.type === "folder" ? node.id : node.parentId;
|
||||||
@@ -148,7 +148,7 @@ function TreeNode({
|
|||||||
<span>新建文件夹</span>
|
<span>新建文件夹</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const targetParentId = node.type === "folder" ? node.id : node.parentId;
|
const targetParentId = node.type === "folder" ? node.id : node.parentId;
|
||||||
@@ -160,7 +160,7 @@ function TreeNode({
|
|||||||
<span>导入文档...</span>
|
<span>导入文档...</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (node.type === "folder") {
|
if (node.type === "folder") {
|
||||||
@@ -175,7 +175,7 @@ function TreeNode({
|
|||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Separator className="h-px bg-muted my-1" />
|
<DropdownMenu.Separator className="h-px bg-muted my-1" />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-red-50 text-red-600 cursor-pointer outline-none"
|
className="flex 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) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
|
|||||||
Reference in New Issue
Block a user