对移动端的UI进行了重构

This commit is contained in:
2026-02-25 10:33:37 +08:00
parent ee3de1968c
commit 5709ef2966
16 changed files with 689 additions and 562 deletions
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -113,10 +113,10 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
}
const trimmed = body.title.trim();
if (!trimmed || trimmed.length > MAX_TITLE_LENGTH) {
if (trimmed.length > MAX_TITLE_LENGTH) {
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
}
updateData.title = trimmed;
updateData.title = trimmed || '无标题';
}
if (body.content !== undefined) {
+85 -30
View File
@@ -1,4 +1,4 @@
@import 'highlight.js/styles/atom-one-dark.css';
@import 'highlight.js/styles/atom-one-dark.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -26,6 +26,7 @@
--input: 35 15% 85%;
--ring: 223 21% 30%;
--radius: 0.75rem;
--code-bg: 220 16% 21%;
}
.dark {
@@ -57,6 +58,7 @@
--border: 220 8% 28%;
--input: 220 8% 28%;
--ring: 210 16% 78%;
--code-bg: 220 16% 20%;
}
* {
@@ -76,7 +78,8 @@
@layer components {
.ui-card {
@apply rounded-2xl border border-border/70 bg-card/95 shadow-sm backdrop-blur-sm;
@apply rounded-xl border border-border/65 bg-card shadow-sm backdrop-blur-sm;
background-color: hsl(var(--card) / 0.92);
}
.ui-input {
@@ -88,23 +91,23 @@
}
.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];
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-transparent px-4 py-2 text-sm font-semibold transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-55 disabled:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25;
}
.ui-btn-primary {
@apply ui-btn bg-primary text-primary-foreground hover:opacity-90 shadow-sm;
@apply ui-btn border-primary/20 bg-primary text-primary-foreground shadow-sm hover:opacity-90;
}
.ui-btn-secondary {
@apply ui-btn bg-secondary text-secondary-foreground hover:bg-secondary/80;
@apply ui-btn border-border/70 bg-secondary/85 text-secondary-foreground hover:bg-secondary;
}
.ui-btn-danger {
@apply ui-btn bg-destructive text-destructive-foreground hover:opacity-90;
@apply ui-btn border-destructive/25 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;
@apply inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-muted-foreground transition-colors active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-55 disabled:active:scale-100 hover:border-border hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25;
}
.ui-kbd {
@@ -159,11 +162,72 @@
.ProseMirror {
outline: none;
min-height: 300px;
padding-bottom: 50px;
padding-bottom: 56px;
font-size: 1.05rem;
line-height: var(--editor-line-height, 1.5);
/* Use CSS variable with fallback */
/* Reduced from 1.75 */
letter-spacing: 0.003em;
}
@media (max-width: 768px) {
.ProseMirror {
font-size: 1rem;
line-height: max(1.55, var(--editor-line-height, 1.5));
}
}
.ProseMirror > * + * {
margin-top: 0.7em;
}
.ProseMirror h1,
.ProseMirror h2,
.ProseMirror h3 {
color: hsl(var(--foreground));
font-weight: 750;
line-height: 1.28;
letter-spacing: -0.01em;
}
.ProseMirror h1 {
margin-top: 1.2em;
font-size: clamp(1.7rem, 1.3rem + 1.3vw, 2.15rem);
}
.ProseMirror h2 {
margin-top: 1.05em;
font-size: clamp(1.35rem, 1.18rem + 0.8vw, 1.7rem);
}
.ProseMirror h3 {
margin-top: 0.9em;
font-size: clamp(1.12rem, 1.02rem + 0.45vw, 1.3rem);
}
.ProseMirror p {
color: hsl(var(--foreground));
}
.ProseMirror ul,
.ProseMirror ol {
margin: 0.5em 0;
padding-left: 1.2em;
}
.ProseMirror li {
margin: 0.22em 0;
}
.ProseMirror a {
color: hsl(var(--primary));
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.ProseMirror hr {
margin: 1.1em 0;
border: 0;
border-top: 1px solid hsl(var(--border));
}
.ProseMirror p.is-editor-empty:first-child::before {
@@ -176,20 +240,15 @@
.ProseMirror blockquote {
border-left: 3px solid hsl(var(--primary));
/* Make border color slightly more visible (primary) */
padding-left: 1rem;
padding-left: 0.95rem;
color: hsl(var(--muted-foreground));
font-family: "Georgia", "Cambria", "Times New Roman", serif !important;
/* Force serif font */
font-style: italic;
margin: 1rem 0;
margin: 0.95rem 0;
background: hsl(var(--muted) / 0.3);
/* Subtle background */
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-top: 0.45rem;
padding-bottom: 0.45rem;
border-radius: 0 0.5rem 0.5rem 0;
/* Rounded right corners */
/* Rounded right corners */
}
.ProseMirror code {
@@ -217,19 +276,16 @@
/* Table Styles - Ensure content is compact */
.ProseMirror table p {
margin: 0;
/* Remove paragraph margin inside tables */
line-height: var(--table-line-height, 1.2);
/* Use CSS variable with fallback */
/* Tighter line height for table content */
}
.ProseMirror table {
border-collapse: collapse;
margin: 1em 0;
/* Add some vertical space around the table itself */
overflow: hidden;
table-layout: fixed;
width: 100%;
border-radius: 0.5rem;
}
.ProseMirror td,
@@ -240,7 +296,6 @@
box-sizing: border-box;
min-width: 1em;
padding: 2px 4px;
/* Further reduced padding */
position: relative;
vertical-align: top;
}
@@ -249,13 +304,12 @@
background-color: hsl(var(--muted));
font-weight: bold;
text-align: left;
/* Make header borders visible by using a contrasting color if bg matches border */
border-color: hsl(var(--foreground) / 0.1);
}
/* AI Content Style */
.ai-content {
font-family: "KaiTi", "STKaiti", "楷体", "Georgia", serif;
font-family: "KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", "Georgia", serif;
font-style: italic;
font-weight: normal;
color: hsl(var(--foreground));
@@ -265,7 +319,6 @@
/* Specific fix for dark mode if needed, but opacity trick usually works */
.dark .ProseMirror th {
border-color: hsl(var(--background));
/* Use background color for borders in dark mode header to show separation */
}
/* Task List Styles */
@@ -279,7 +332,6 @@ ul[data-type="taskList"],
.ProseMirror li[data-type="taskItem"] div,
.ProseMirror li[data-type="taskItem"] label {
line-height: normal !important;
/* Force tight line height for checkbox items */
margin-top: 2px;
margin-bottom: 2px;
}
@@ -289,12 +341,15 @@ ul[data-type="taskList"],
/* Override Highlight.js background for a softer look */
.ProseMirror pre {
background: #2b313a !important;
/* Slightly lifted code block background for comfortable reading */
background: hsl(var(--code-bg)) !important;
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 0.5rem;
padding: 0.65rem 0.75rem;
margin: 0.95rem 0;
}
.hljs {
background: transparent !important;
/* Let pre handle the background */
}
+94 -55
View File
@@ -3,7 +3,7 @@
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
import { Editor } from "@/components/editor";
import { useEditorStore } from "@/lib/store";
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react";
import { Hash, X, Plus, ChevronLeft, ChevronRight, ChevronDown, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react";
import { useSettingsStore } from "@/lib/settings-store";
import { exportPageAsMarkdown } from "@/lib/export";
import { useMemo, useState } from "react";
@@ -12,6 +12,7 @@ import { useSearchStore } from "@/lib/search-store";
import { type Editor as TiptapEditor } from "@tiptap/react";
import { ImportProvider } from "@/components/import-context";
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
import { SearchCommand } from "@/components/search-command";
import { getBacklinks, getOutgoingLinks, getUnresolvedLinkTitles } from "@/lib/wiki-links";
import { getPageHistory } from "@/lib/page-history";
@@ -34,6 +35,8 @@ export default function Home() {
const [isAddingTag, setIsAddingTag] = useState(false);
const [tagInput, setTagInput] = useState("");
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
const breadcrumbs = useMemo(() => {
if (!activePage) return [];
@@ -68,6 +71,7 @@ export default function Home() {
if (!activePage || activePage.type !== "file") return [];
return getPageHistory(activePage.id);
}, [activePage]);
const effectiveHistoryIndex = Math.min(selectedHistoryIndex, Math.max(localHistory.length - 1, 0));
const addTag = () => {
if (!activePage) return;
@@ -93,6 +97,8 @@ export default function Home() {
return (
<ImportProvider>
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
<SearchCommand />
<div className={cn(activePageId ? "hidden" : "block h-full flex-1 md:hidden")}>
<SidebarContent />
</div>
@@ -102,50 +108,50 @@ export default function Home() {
<main className={cn("relative z-0 flex h-full flex-1 flex-col overflow-hidden", !activePageId && "hidden md:flex")}>
{activePage ? (
<div className="flex-1 overflow-y-auto scroll-smooth">
<div className="mx-auto min-h-screen max-w-7xl px-4 py-6 md:px-16">
<div className="ui-enter group relative mb-8">
<div className="mx-auto min-h-screen max-w-7xl px-3 py-4 md:px-16 md:py-6">
<div className="ui-enter group relative mb-6 pt-10 md:mb-8 md:pt-0">
<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"
className="absolute left-0 top-0 flex h-9 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="mb-4 flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
<div className="mb-3 flex flex-wrap items-center gap-1 text-xs text-muted-foreground md:mb-4 md:text-sm">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.id} className="flex items-center gap-1">
<div key={crumb.id} className="flex min-w-0 items-center gap-1">
{index > 0 && <ChevronRight size={14} className="opacity-50" />}
<button
onClick={() => useEditorStore.getState().setActivePageId(crumb.id)}
className={cn(
"flex items-center gap-1 transition-colors hover:text-foreground hover:underline",
"flex min-w-0 items-center gap-1 transition-colors hover:text-foreground hover:underline",
crumb.id === activePage.id && "pointer-events-none font-medium text-foreground"
)}
>
{crumb.icon && <span>{crumb.icon}</span>}
<span>{crumb.title || "无标题"}</span>
<span className="truncate">{crumb.title || "无标题"}</span>
</button>
</div>
))}
</div>
<div className="flex items-center gap-2">
{activePage.icon && <span className="select-none text-3xl">{activePage.icon}</span>}
<div className="flex items-start gap-2">
{activePage.icon && <span className="select-none pt-0.5 text-[28px] md:text-3xl">{activePage.icon}</span>}
<input
value={activePage.title}
onChange={(e) => updatePage(activePage.id, { title: e.target.value })}
placeholder="无标题"
disabled={activePage.isLocked}
className={cn(
"w-full border-none bg-transparent text-3xl font-bold text-foreground outline-none placeholder:text-muted-foreground/30",
"w-full border-none bg-transparent text-2xl font-bold leading-tight text-foreground outline-none placeholder:text-muted-foreground/30 md:text-3xl",
activePage.isLocked && "cursor-not-allowed select-none opacity-80"
)}
/>
</div>
</div>
<div className="ui-enter-delayed mb-6 flex flex-wrap items-center gap-2">
<div className="ui-enter-delayed relative z-20 mb-6 flex flex-wrap items-center gap-2">
{activePage.tags?.map((tag) => {
const colors = getTagColor(tag);
return (
@@ -167,7 +173,7 @@ export default function Home() {
const newTags = activePage.tags?.filter((t) => t !== tag) || [];
updatePage(activePage.id, { tags: newTags });
}}
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"
className="ml-1 rounded-full p-1 opacity-100 transition-all hover:bg-black/10 md:p-0.5 md:opacity-0 md:group-hover/tag:opacity-100 dark:hover:bg-white/10"
title="移除标签"
>
<X size={10} />
@@ -205,7 +211,7 @@ export default function Home() {
</button>
)}
<div className="relative">
<div className="relative z-30">
<button
onClick={() => setIsIconPickerOpen((v) => !v)}
className="ui-btn-secondary px-2 py-1 text-xs text-muted-foreground"
@@ -213,8 +219,8 @@ export default function Home() {
<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">
<div className="ui-card absolute left-0 top-full z-[120] mt-1 w-[min(92vw,20rem)] p-2 shadow-xl animate-in fade-in zoom-in-95 sm:w-80">
<div className="grid h-48 grid-cols-6 gap-1 overflow-y-auto pr-1 sm:grid-cols-8">
{ICONS.map((icon) => (
<button
key={icon}
@@ -258,7 +264,7 @@ export default function Home() {
</button>
{activePage.updatedAt && (
<div className="ml-1 h-4 border-l pl-2 text-xs text-muted-foreground/50">
<div className="w-full pt-0.5 text-[11px] text-muted-foreground/60 md:ml-1 md:h-4 md:w-auto md:border-l md:pl-2 md:pt-0 md:text-xs md:text-muted-foreground/50">
{new Date(activePage.updatedAt).toLocaleString("zh-CN", {
timeZone: timezone || "Asia/Shanghai",
hour12: false,
@@ -268,7 +274,7 @@ export default function Home() {
</div>
{activePage.type === "folder" ? (
<div className="ui-card ui-enter flex h-[50vh] flex-col items-center justify-center gap-3 text-muted-foreground">
<div className="ui-card ui-enter flex h-[45vh] flex-col items-center justify-center gap-3 text-muted-foreground md:h-[50vh]">
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
<FolderOpen size={34} className="text-primary/80" />
</div>
@@ -287,8 +293,8 @@ export default function Home() {
editable={!activePage.isLocked}
/>
<section className="ui-enter mt-8 space-y-4">
<div className="ui-card p-4">
<section className="ui-enter mt-6 space-y-3 md:mt-8 md:space-y-4">
<div className="ui-card p-3 md:p-4">
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="mt-1 text-xs text-muted-foreground">使 `[[页面名]]` `Ctrl/Cmd + 点击` </p>
<div className="mt-3 flex flex-wrap gap-2">
@@ -309,7 +315,7 @@ export default function Home() {
</div>
</div>
<div className="ui-card p-4">
<div className="ui-card p-3 md:p-4">
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="mt-1 text-xs text-muted-foreground"></p>
<div className="mt-3 flex flex-wrap gap-2">
@@ -330,7 +336,7 @@ export default function Home() {
</div>
</div>
<div className="ui-card p-4">
<div className="ui-card p-3 md:p-4">
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="mt-1 text-xs text-muted-foreground"> `[[页面名]]` </p>
<div className="mt-3 flex flex-wrap gap-2">
@@ -353,47 +359,78 @@ export default function Home() {
</div>
</div>
<div className="ui-card p-4">
<div className="flex items-center gap-2">
<History size={14} className="text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<div className="ui-card p-3 md:p-4">
<button
onClick={() => setIsHistoryOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/45"
aria-expanded={isHistoryOpen}
aria-label="切换本地历史版本面板"
>
<div className="flex items-center gap-2">
<History size={14} className="text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{localHistory.length}</span>
</div>
<ChevronDown size={15} className={cn("text-muted-foreground transition-transform", isHistoryOpen && "rotate-180")} />
</button>
<p className="mt-1 text-xs text-muted-foreground"></p>
<div className="mt-3 space-y-2">
{localHistory.length > 0 ? (
localHistory.slice(0, 8).map((snapshot, index) => (
<div key={`${snapshot.timestamp}-${index}`} className="flex items-center justify-between rounded-md border border-border/60 bg-muted/20 px-2.5 py-2">
<div className="min-w-0">
{isHistoryOpen && (
<div className="mt-3 space-y-3">
{localHistory.length > 0 ? (
<>
<div className="rounded-lg border border-border/60 bg-muted/20 p-2">
<label className="mb-1 block text-[11px] text-muted-foreground"></label>
<select
value={selectedHistoryIndex}
onChange={(e) => setSelectedHistoryIndex(parseInt(e.target.value, 10))}
className="ui-select h-9 py-1 text-xs"
>
{localHistory.slice(0, 20).map((snapshot, index) => (
<option key={`${snapshot.timestamp}-${index}`} value={index}>
{new Date(snapshot.timestamp).toLocaleString("zh-CN", {
timeZone: timezone || "Asia/Shanghai",
hour12: false,
})}
{` · ${snapshot.title || "无标题"}`}
</option>
))}
</select>
</div>
<div className="rounded-md border border-border/60 bg-muted/15 px-2.5 py-2">
<div className="truncate text-xs text-foreground">
{snapshot.title || "无标题"}
{localHistory[effectiveHistoryIndex]?.title || "无标题"}
</div>
<div className="text-[11px] text-muted-foreground">
{new Date(snapshot.timestamp).toLocaleString("zh-CN", {
timeZone: timezone || "Asia/Shanghai",
hour12: false,
})}
{localHistory[effectiveHistoryIndex]
? new Date(localHistory[effectiveHistoryIndex].timestamp).toLocaleString("zh-CN", {
timeZone: timezone || "Asia/Shanghai",
hour12: false,
})
: "-"}
</div>
</div>
<button
onClick={async () => {
const target = localHistory[effectiveHistoryIndex];
if (!target || !activePage) return;
const ok = window.confirm("恢复该历史版本后将覆盖当前内容,是否继续?");
if (!ok || !activePage) return;
if (!ok) return;
await updatePage(activePage.id, {
title: snapshot.title,
content: snapshot.content,
title: target.title,
content: target.content,
});
}}
className="ui-btn-secondary px-2 py-1 text-xs"
className="ui-btn-secondary h-9 w-full justify-center px-2 py-1 text-xs sm:h-auto sm:w-auto sm:justify-start"
>
<RotateCcw size={12} />
</button>
</div>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
)}
</div>
</section>
</div>
@@ -401,13 +438,15 @@ export default function Home() {
</div>
</div>
) : (
<div className="ui-enter flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
<span className="text-4xl">🧠</span>
</div>
<div className="space-y-1 text-center">
<h3 className="text-lg font-semibold text-foreground">使 NoteAI</h3>
<p className="text-sm text-muted-foreground/90"></p>
<div className="ui-enter flex h-full items-center justify-center p-4 md:p-8">
<div className="ui-card flex w-full max-w-md flex-col items-center gap-3 px-6 py-8 text-center text-muted-foreground">
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
<span className="text-4xl">🧠</span>
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">使 NoteAI</h3>
<p className="text-sm text-muted-foreground/90"></p>
</div>
</div>
</div>
)}
+36 -19
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Lock, Save, Sparkles, Type, Upload, Download } from "lucide-react";
import { Lock, Save, Sparkles, Type, Upload, Download, Loader2 } from "lucide-react";
import { ResizableSidebar } from "@/components/sidebar";
import { PromptManagement } from "@/components/settings/prompt-management";
import { ImportProvider } from "@/components/import-context";
@@ -28,6 +28,8 @@ export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isExporting, setIsExporting] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const handlePasswordChange = async (e: React.FormEvent) => {
@@ -62,7 +64,7 @@ export default function SettingsPage() {
return (
<ImportProvider>
<div className="flex h-screen w-full bg-background overflow-hidden">
<div className="flex h-[100dvh] w-full overflow-hidden bg-background">
<ResizableSidebar />
<main className="flex-1 h-full overflow-y-auto p-4 md:p-10">
<div className="mx-auto max-w-3xl space-y-8 pb-16">
@@ -71,10 +73,10 @@ export default function SettingsPage() {
<p className="text-muted-foreground">AI</p>
</header>
<section className="ui-card ui-enter-delayed p-6 space-y-5">
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
<div className="flex items-center gap-2 border-b pb-2">
<Type size={18} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
<h2 className="text-lg font-semibold md:text-xl"></h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -149,10 +151,10 @@ export default function SettingsPage() {
</label>
</section>
<section className="ui-card ui-enter-delayed p-6 space-y-5">
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
<div className="flex items-center gap-2 border-b pb-2">
<Sparkles size={18} className="text-primary" />
<h2 className="text-xl font-semibold">AI </h2>
<h2 className="text-lg font-semibold md:text-xl">AI </h2>
</div>
<div className="space-y-3">
@@ -191,14 +193,14 @@ export default function SettingsPage() {
</div>
</section>
<section className="ui-card ui-enter-delayed p-6">
<section className="ui-card ui-enter-delayed p-4 md:p-6">
<PromptManagement />
</section>
<section className="ui-card ui-enter-delayed p-6 space-y-5">
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
<div className="flex items-center gap-2 border-b pb-2">
<Lock size={18} className="text-primary" />
<h2 className="text-xl font-semibold"></h2>
<h2 className="text-lg font-semibold md:text-xl"></h2>
</div>
<form onSubmit={handlePasswordChange} className="space-y-3 max-w-md">
@@ -243,30 +245,43 @@ export default function SettingsPage() {
</form>
</section>
<section className="ui-card ui-enter-delayed p-6 space-y-4">
<h2 className="text-xl font-semibold"></h2>
<section className="ui-card ui-enter-delayed space-y-4 p-4 md:p-6">
<h2 className="text-lg font-semibold md:text-xl"></h2>
<p className="text-sm text-muted-foreground"></p>
<div className="flex flex-col gap-3 sm:flex-row">
<button
onClick={async () => {
const { pages } = await import("@/lib/store").then((m) => m.useEditorStore.getState());
const { exportAllPagesAsZip } = await import("@/lib/export");
await exportAllPagesAsZip(pages);
if (isExporting || isRestoring) return;
setIsExporting(true);
try {
const { pages } = await import("@/lib/store").then((m) => m.useEditorStore.getState());
const { exportAllPagesAsZip } = await import("@/lib/export");
await exportAllPagesAsZip(pages);
} finally {
setIsExporting(false);
}
}}
disabled={isExporting || isRestoring}
aria-busy={isExporting}
className="ui-btn-secondary"
>
<Download size={14} />
(Zip)
{isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
{isExporting ? "导出中..." : "下载备份 (Zip)"}
</button>
<label className="ui-btn inline-flex cursor-pointer border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20">
<Upload size={14} />
<label
className={`ui-btn inline-flex border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20 ${
isExporting || isRestoring ? "cursor-not-allowed opacity-55" : "cursor-pointer"
}`}
>
{isRestoring ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
{isRestoring ? "恢复中..." : "上传备份并恢复"}
<input
type="file"
accept=".zip"
className="hidden"
disabled={isExporting || isRestoring}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -284,6 +299,7 @@ export default function SettingsPage() {
}
try {
setIsRestoring(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/settings/restore", { method: "POST", body: formData });
@@ -295,6 +311,7 @@ export default function SettingsPage() {
} catch (error) {
alert(String(error));
} finally {
setIsRestoring(false);
e.target.value = "";
}
}}
+3 -3
View File
@@ -18,13 +18,13 @@ export function AIAssist({ onSuggest, isOpen, onClose }: AIAssistProps) {
if (typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/25 backdrop-blur-[2px]" onClick={onClose}>
<div className="ui-card w-[320px] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/25 p-3 backdrop-blur-[2px]" onClick={onClose}>
<div className="ui-card w-full max-w-[320px] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b border-border/60 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<Sparkles size={16} className="text-primary" />
AI
</div>
<div className="max-h-[320px] space-y-1 overflow-y-auto p-2">
<div className="max-h-[55vh] space-y-1 overflow-y-auto p-2 sm:max-h-[320px]">
{prompts.map((prompt) => (
<button
key={prompt.id}
+29 -9
View File
@@ -39,6 +39,18 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -141,7 +153,12 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
if (!isOpen) return null;
return (
<div className="fixed bottom-0 right-0 top-0 z-50 flex w-96 flex-col border-l border-border/70 bg-background shadow-xl animate-in slide-in-from-right duration-300">
<div
className="fixed inset-0 z-50 flex w-full flex-col bg-background shadow-xl animate-in slide-in-from-right duration-300 md:left-auto md:w-96 md:border-l md:border-border/70"
role="dialog"
aria-modal="true"
aria-label="AI 助手对话面板"
>
<div className="flex items-center justify-between border-b border-border/70 p-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
@@ -151,8 +168,8 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
</div>
</div>
<div className="flex items-center gap-2">
<span className="mr-1 text-xs text-muted-foreground"> Tokens: {tokenCount}</span>
<button onClick={onClose} className="ui-icon-btn h-8 w-8">
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline"> Tokens: {tokenCount}</span>
<button onClick={onClose} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" aria-label="关闭 AI 助手">
<X size={18} />
</button>
</div>
@@ -177,7 +194,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
</div>
<div
className={cn(
"group relative max-w-[85%] rounded-lg p-3 text-sm",
"group relative max-w-[92%] rounded-lg p-3 text-sm sm:max-w-[85%]",
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"
)}
@@ -192,7 +209,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
)}
{msg.role === "assistant" && !isLoading && (
<div className="absolute -bottom-6 left-0 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<div className="mt-2 flex gap-2 opacity-100 sm:absolute sm:-bottom-6 sm:left-0 sm:mt-0 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100">
<button
onClick={() => handleInsert(msg.content)}
className="flex items-center gap-1 rounded border bg-background px-1 py-1 text-xs shadow hover:bg-muted"
@@ -215,20 +232,21 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
<div ref={messagesEndRef} />
</div>
<div className="border-t border-border/70 bg-muted/20 p-4">
<div className="border-t border-border/70 bg-muted/20 p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:p-4">
<div className="mb-2 flex items-center justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<label className="flex cursor-pointer select-none items-center gap-2 text-[11px] text-muted-foreground sm:text-xs">
<input
type="checkbox"
checked={useContext}
onChange={(e) => setUseContext(e.target.checked)}
className="rounded border-input text-primary focus:ring-primary"
className="h-5 w-5 rounded border-input text-primary focus:ring-primary"
aria-label="关联当前文档上下文"
/>
</label>
<button
onClick={() => setMessages([])}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground sm:text-xs"
title="清空对话"
>
<Eraser size={12} />
@@ -246,6 +264,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
}}
placeholder="输入消息..."
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"
aria-label="输入给 AI 的消息"
/>
<button
onClick={isLoading ? handleStop : handleSend}
@@ -255,6 +274,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
isLoading ? "bg-red-500 text-white" : "bg-primary text-primary-foreground"
)}
title={isLoading ? "停止生成" : "发送"}
aria-label={isLoading ? "停止生成" : "发送消息"}
>
{isLoading ? <div className="h-4 w-4 animate-pulse rounded-sm bg-current" /> : <Send size={16} />}
</button>
+2 -1
View File
@@ -71,13 +71,14 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" onClick={() => closeWith(false)} className="ui-btn-secondary">
<button type="button" onClick={() => closeWith(false)} className="ui-btn-secondary" aria-label={request?.cancelText || "取消"}>
{request?.cancelText || "取消"}
</button>
<button
type="button"
onClick={() => closeWith(true)}
className={request?.tone === "danger" ? "ui-btn-danger" : "ui-btn-primary"}
aria-label={request?.confirmText || "确认"}
>
{request?.confirmText || "确认"}
</button>
+2 -2
View File
@@ -287,7 +287,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
</div>
)}
<div className="flex-1 mt-4">
<div className="mt-3 flex-1 md:mt-4">
<EditorContent editor={editor} />
</div>
@@ -300,7 +300,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
setShowAI(!showAI);
}
}}
className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${
className={`fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 md:bottom-8 md:right-8 ${
isGenerating ? "bg-red-500 text-white animate-pulse" : "bg-primary text-primary-foreground"
}`}
title={isGenerating ? "停止生成" : "AI 助手"}
+31 -77
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import { NodeViewContent, NodeViewWrapper, NodeViewProps } from "@tiptap/react";
import React, { useMemo } from "react";
@@ -13,135 +13,89 @@ export function CodeBlockComponent({
editor,
getPos,
}: NodeViewProps) {
const codeBg = "#2b313a";
const codeBg = "hsl(var(--code-bg))";
const { language: defaultLanguage } = node.attrs;
const { textContent } = node;
const [copied, setCopied] = React.useState(false);
const languages = extension.options.lowlight ? extension.options.lowlight.listLanguages() : [];
// Custom logic to prioritize common languages and ensure 'html' is present
const popularLanguages = ['html', 'javascript', 'typescript', 'css', 'json', 'python', 'java', 'go', 'bash', 'sql'];
const otherLanguages = languages.filter((lang: string) => !popularLanguages.includes(lang) && lang !== 'xml'); // Hide xml if we show html
// We map 'html' to 'xml' when saving if needed, but for the selector we want 'html'
// Actually highlight.js understands 'html' if aliased.
// Let's just create a flat list for the selector, but maybe we want groups?
// For now, just a flat sorted list: Popular + Others
const popularLanguages = ["html", "javascript", "typescript", "css", "json", "python", "java", "go", "bash", "sql"];
const otherLanguages = languages.filter((lang: string) => !popularLanguages.includes(lang) && lang !== "xml");
const displayLanguages = Array.from(new Set([...popularLanguages, ...otherLanguages]));
const insertAbove = () => {
if (typeof getPos === 'function') {
if (typeof getPos === "function") {
const pos = getPos();
if (typeof pos !== 'number') return;
editor.chain()
.insertContentAt(pos, { type: 'paragraph' })
.focus()
.setTextSelection(pos + 1)
.scrollIntoView()
.run();
if (typeof pos !== "number") return;
editor.chain().insertContentAt(pos, { type: "paragraph" }).focus().setTextSelection(pos + 1).scrollIntoView().run();
}
};
const insertBelow = () => {
if (typeof getPos === 'function') {
if (typeof getPos === "function") {
const pos = getPos();
if (typeof pos !== 'number') return;
if (typeof pos !== "number") return;
const targetPos = pos + node.nodeSize;
editor.chain()
.insertContentAt(targetPos, { type: 'paragraph' })
.focus()
.setTextSelection(targetPos + 1)
.scrollIntoView()
.run();
editor.chain().insertContentAt(targetPos, { type: "paragraph" }).focus().setTextSelection(targetPos + 1).scrollIntoView().run();
}
};
const handleCopy = () => {
const code = textContent;
navigator.clipboard.writeText(code);
navigator.clipboard.writeText(textContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Calculate line numbers
const lineNumbers = useMemo(() => {
const lines = textContent.split('\n').length;
const lines = textContent.split("\n").length;
return Array.from({ length: lines }, (_, i) => i + 1);
}, [textContent]);
return (
<NodeViewWrapper className="relative group code-block rounded-lg border border-border/40 my-4 shadow-sm overflow-hidden" style={{ backgroundColor: codeBg }}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-[#2f2f35] border-b border-white/5 text-xs select-none text-zinc-400">
<NodeViewWrapper className="group code-block relative my-4 overflow-hidden rounded-lg border border-border/40 shadow-sm" style={{ backgroundColor: codeBg }}>
<div
className="flex select-none items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-zinc-400"
style={{ backgroundColor: "hsl(var(--code-bg) / 0.9)" }}
>
<div className="flex items-center gap-2">
<LanguageSelector
language={defaultLanguage || 'null'}
language={defaultLanguage || "null"}
onChange={(val) => {
// Map helpful aliases if necessary, though highlight.js is smart.
// But Tiptap highlight extension might strict check?
// Usually 'html' works fine if the grammar is loaded.
// Since we use 'common', 'xml' is the grammar. 'html' is alias.
// If I pass 'html', does lowlight find it? Yes if alias is registered.
// Earlier we tried to register alias but reverted.
// Let's force 'xml' if 'html' is selected to be safe, OR rely on 'xml' being the backend.
// Actually, let's keep it simple: Pass 'val'.
updateAttributes({ language: val })
updateAttributes({ language: val });
}}
languages={displayLanguages}
/>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={insertAbove}
className="hover:text-white hover:bg-white/10 p-1.5 rounded transition-all"
title="在上方插入"
>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button onClick={insertAbove} className="rounded p-1.5 transition-all hover:bg-white/10 hover:text-white" title="在上方插入">
<ArrowUpToLine size={14} />
</button>
<button
onClick={insertBelow}
className="hover:text-white hover:bg-white/10 p-1.5 rounded transition-all"
title="在下方插入"
>
<button onClick={insertBelow} className="rounded p-1.5 transition-all hover:bg-white/10 hover:text-white" title="在下方插入">
<ArrowDownToLine size={14} />
</button>
<div className="w-px h-3 bg-white/10 mx-1" />
<button
onClick={handleCopy}
className="hover:text-white hover:bg-white/10 p-1.5 rounded transition-all"
title="复制"
>
<div className="mx-1 h-3 w-px bg-white/10" />
<button onClick={handleCopy} className="rounded p-1.5 transition-all hover:bg-white/10 hover:text-white" title="复制代码">
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
<button
onClick={() => deleteNode()}
className="hover:text-red-400 hover:bg-white/10 p-1.5 rounded transition-all"
title="删除"
>
<button onClick={() => deleteNode()} className="rounded p-1.5 transition-all hover:bg-white/10 hover:text-red-400" title="删除代码块">
<Trash2 size={14} />
</button>
</div>
</div>
{/* Code Area with Line Numbers - Use grid for better alignment control */}
<div className="relative grid grid-cols-[auto_1fr] font-mono text-sm leading-6" style={{ backgroundColor: codeBg }}>
{/* Line Numbers Gutter */}
<div
className="py-4 px-2 text-right select-none border-r border-white/5 text-zinc-500"
style={{ minWidth: '2.5rem' }}
contentEditable={false}
>
<div className="select-none border-r border-white/5 px-2 py-4 text-right text-zinc-500" style={{ minWidth: "2.5rem" }} contentEditable={false}>
{lineNumbers.map((line) => (
<div key={line} className="px-1">{line}</div>
<div key={line} className="px-1">
{line}
</div>
))}
</div>
{/* Actual Code Content */}
<pre className="overflow-x-auto !p-0 !my-0 !border-0 text-zinc-300 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent" style={{ backgroundColor: codeBg }}>
<NodeViewContent className="block min-w-full !p-4 !bg-transparent !whitespace-pre outline-none !font-mono !text-sm !leading-6" />
<pre className="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-white/10 !my-0 !border-0 !p-0 overflow-x-auto text-zinc-300" style={{ backgroundColor: codeBg }}>
<NodeViewContent className="block min-w-full !whitespace-pre !bg-transparent !p-4 !font-mono !text-sm !leading-6 outline-none" />
</pre>
</div>
</NodeViewWrapper>
+2 -2
View File
@@ -118,8 +118,8 @@ export class CommandList extends Component<CommandListProps, CommandListState> {
let globalIndex = 0;
return (
<div className="z-50 h-auto max-h-[500px] w-[800px] overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950/95 shadow-2xl backdrop-blur-lg animate-in fade-in zoom-in-95 duration-200">
<div className="columns-3 gap-2 p-3 space-y-4">
<div className="z-50 h-auto max-h-[65vh] w-[min(92vw,800px)] overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950/95 shadow-2xl backdrop-blur-lg animate-in fade-in zoom-in-95 duration-200">
<div className="columns-1 gap-2 p-2 space-y-3 sm:p-3 sm:space-y-4 md:columns-2 lg:columns-3">
{Object.entries(grouped).map(([groupName, groupItems]) => (
<div key={groupName} className="break-inside-avoid mb-4">
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500 select-none mb-1">
+71 -158
View File
@@ -1,13 +1,32 @@
"use client";
"use client";
import { type Editor } from "@tiptap/react";
import {
Bold, Italic, Strikethrough, Code,
Heading1, Heading2, Heading3,
List, ListOrdered, Quote,
Undo, Redo, Minus, RemoveFormatting,
AlignLeft, AlignCenter, AlignRight, CheckSquare, Link as LinkIcon, Image as ImageIcon,
Table as TableIcon, Sparkles, FileDown, SquareCode
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Undo,
Redo,
Minus,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
CheckSquare,
Link as LinkIcon,
Image as ImageIcon,
Table as TableIcon,
Sparkles,
FileDown,
SquareCode,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -17,127 +36,43 @@ interface ToolbarProps {
onExport?: () => void;
}
type ToolbarItem = {
icon?: LucideIcon;
title?: string;
action?: () => void;
isActive?: boolean;
disabled?: boolean;
divider?: boolean;
};
export function Toolbar({ editor, onToggleAI, onExport }: ToolbarProps) {
if (!editor) {
return null;
}
const items = [
{
icon: Bold,
title: "加粗",
action: () => editor.chain().focus().toggleBold().run(),
isActive: editor.isActive("bold"),
},
{
icon: Italic,
title: "斜体",
action: () => editor.chain().focus().toggleItalic().run(),
isActive: editor.isActive("italic"),
},
{
icon: Strikethrough,
title: "删除线",
action: () => editor.chain().focus().toggleStrike().run(),
isActive: editor.isActive("strike"),
},
{
icon: Code,
title: "行内代码",
action: () => editor.chain().focus().toggleCode().run(),
isActive: editor.isActive("code"),
},
{
icon: SquareCode,
title: "代码块",
action: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: editor.isActive("codeBlock"),
},
{
divider: true,
},
{
icon: Heading1,
title: "标题 1",
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: editor.isActive("heading", { level: 1 }),
},
{
icon: Heading2,
title: "标题 2",
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: editor.isActive("heading", { level: 2 }),
},
{
icon: Heading3,
title: "标题 3",
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: editor.isActive("heading", { level: 3 }),
},
{
divider: true,
},
{
icon: List,
title: "无序列表",
action: () => editor.chain().focus().toggleBulletList().run(),
isActive: editor.isActive("bulletList"),
},
{
icon: ListOrdered,
title: "有序列表",
action: () => editor.chain().focus().toggleOrderedList().run(),
isActive: editor.isActive("orderedList"),
},
{
icon: Quote,
title: "引用",
action: () => editor.chain().focus().toggleBlockquote().run(),
isActive: editor.isActive("blockquote"),
},
{
divider: true,
},
{
icon: Minus,
title: "水平分割线",
action: () => editor.chain().focus().setHorizontalRule().run(),
},
{
icon: RemoveFormatting,
title: "清除格式",
action: () => editor.chain().focus().unsetAllMarks().clearNodes().run(),
},
{
divider: true,
},
{
icon: AlignLeft,
title: "左对齐",
action: () => editor.chain().focus().setTextAlign("left").run(),
isActive: editor.isActive({ textAlign: "left" }),
},
{
icon: AlignCenter,
title: "居中对齐",
action: () => editor.chain().focus().setTextAlign("center").run(),
isActive: editor.isActive({ textAlign: "center" }),
},
{
icon: AlignRight,
title: "右对齐",
action: () => editor.chain().focus().setTextAlign("right").run(),
isActive: editor.isActive({ textAlign: "right" }),
},
{
divider: true,
},
{
icon: CheckSquare,
title: "任务列表",
action: () => editor.chain().focus().toggleTaskList().run(),
isActive: editor.isActive("taskList"),
},
const items: ToolbarItem[] = [
{ icon: Bold, title: "加粗", action: () => editor.chain().focus().toggleBold().run(), isActive: editor.isActive("bold") },
{ icon: Italic, title: "斜体", action: () => editor.chain().focus().toggleItalic().run(), isActive: editor.isActive("italic") },
{ icon: Strikethrough, title: "删除线", action: () => editor.chain().focus().toggleStrike().run(), isActive: editor.isActive("strike") },
{ icon: Code, title: "行内代码", action: () => editor.chain().focus().toggleCode().run(), isActive: editor.isActive("code") },
{ icon: SquareCode, title: "代码块", action: () => editor.chain().focus().toggleCodeBlock().run(), isActive: editor.isActive("codeBlock") },
{ divider: true },
{ icon: Heading1, title: "标题 1", action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), isActive: editor.isActive("heading", { level: 1 }) },
{ icon: Heading2, title: "标题 2", action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), isActive: editor.isActive("heading", { level: 2 }) },
{ icon: Heading3, title: "标题 3", action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), isActive: editor.isActive("heading", { level: 3 }) },
{ divider: true },
{ icon: List, title: "无序列表", action: () => editor.chain().focus().toggleBulletList().run(), isActive: editor.isActive("bulletList") },
{ icon: ListOrdered, title: "有序列表", action: () => editor.chain().focus().toggleOrderedList().run(), isActive: editor.isActive("orderedList") },
{ icon: Quote, title: "引用", action: () => editor.chain().focus().toggleBlockquote().run(), isActive: editor.isActive("blockquote") },
{ divider: true },
{ icon: Minus, title: "分割线", action: () => editor.chain().focus().setHorizontalRule().run() },
{ icon: RemoveFormatting, title: "清除格式", action: () => editor.chain().focus().unsetAllMarks().clearNodes().run() },
{ divider: true },
{ icon: AlignLeft, title: "左对齐", action: () => editor.chain().focus().setTextAlign("left").run(), isActive: editor.isActive({ textAlign: "left" }) },
{ icon: AlignCenter, title: "居中", action: () => editor.chain().focus().setTextAlign("center").run(), isActive: editor.isActive({ textAlign: "center" }) },
{ icon: AlignRight, title: "右对齐", action: () => editor.chain().focus().setTextAlign("right").run(), isActive: editor.isActive({ textAlign: "right" }) },
{ divider: true },
{ icon: CheckSquare, title: "任务列表", action: () => editor.chain().focus().toggleTaskList().run(), isActive: editor.isActive("taskList") },
{
icon: LinkIcon,
title: "链接",
@@ -168,57 +103,35 @@ export function Toolbar({ editor, onToggleAI, onExport }: ToolbarProps) {
if (url) editor.chain().focus().setImage({ src: url }).run();
},
},
{
divider: true,
},
{
icon: Undo,
title: "撤销",
action: () => editor.chain().focus().undo().run(),
disabled: !editor.can().undo(),
},
{
icon: Redo,
title: "重做",
action: () => editor.chain().focus().redo().run(),
disabled: !editor.can().redo(),
},
{
divider: true,
},
{
icon: Sparkles,
title: "AI 助手",
action: () => onToggleAI?.(),
},
{
icon: FileDown,
title: "导出 Markdown",
action: () => onExport?.(),
},
{ divider: true },
{ icon: Undo, title: "撤销", action: () => editor.chain().focus().undo().run(), disabled: !editor.can().undo() },
{ icon: Redo, title: "重做", action: () => editor.chain().focus().redo().run(), disabled: !editor.can().redo() },
{ divider: true },
{ icon: Sparkles, title: "AI 助手", action: () => onToggleAI?.() },
{ icon: FileDown, title: "导出 Markdown", action: () => onExport?.() },
];
return (
<div className="flex items-center gap-1 p-1 bg-background/50 backdrop-blur border-b sticky top-0 z-10 overflow-x-auto no-scrollbar">
{items.map((item, index) => (
<div className="sticky top-0 z-20 -mx-1 mb-2 flex items-center gap-1 overflow-x-auto rounded-xl border border-border/60 bg-background/80 p-1 shadow-[0_1px_0_hsl(var(--border)/0.45),0_10px_20px_hsl(var(--background)/0.24)] backdrop-blur-md no-scrollbar supports-[backdrop-filter]:bg-background/68">
{items.map((item, index) =>
item.divider ? (
<div key={index} className="w-px h-6 bg-border mx-1" />
<div key={index} className="mx-1 h-6 w-px bg-border/80" />
) : (
<button
key={index}
onClick={item.action}
disabled={item.disabled}
className={cn(
"p-2 rounded-md transition-colors hover:bg-muted text-muted-foreground hover:text-foreground",
item.isActive && "bg-accent text-accent-foreground",
item.disabled && "opacity-50 cursor-not-allowed"
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-transparent text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground md:h-9 md:w-9",
item.isActive && "border border-border/60 bg-accent text-accent-foreground shadow-sm",
item.disabled && "cursor-not-allowed opacity-50"
)}
title={item.title}
>
{item.icon && <item.icon size={16} />}
</button>
)
))}
)}
</div>
);
}
+24 -6
View File
@@ -70,6 +70,11 @@ export function SearchCommand() {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(!isOpen);
return;
}
if (e.key === "Escape" && isOpen) {
e.preventDefault();
setOpen(false);
}
};
@@ -97,24 +102,37 @@ export function SearchCommand() {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm animate-in fade-in duration-200">
<div className="fixed inset-0" onClick={() => setOpen(false)} />
<div className="ui-card relative w-full max-w-lg overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-background/70 p-4 pt-[max(1rem,env(safe-area-inset-top))] backdrop-blur-sm animate-in fade-in duration-200 md:items-center"
role="dialog"
aria-modal="true"
aria-label="搜索文档"
>
<div className="fixed inset-0" onClick={() => setOpen(false)} aria-hidden="true" />
<div className="ui-card relative mt-10 w-full max-w-lg overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200 md:mt-0">
<Command className="w-full" shouldFilter={false}>
<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" aria-hidden="true" />
<Command.Input
placeholder="搜索... 支持 tag:项目 type:file updated:7d"
value={query}
onValueChange={setQuery}
className="flex h-12 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
aria-label="搜索文档输入框"
autoFocus
/>
</div>
<div className="border-b border-border/50 px-3 py-2 text-[11px] text-muted-foreground">
`tag:标签` `type:file|folder` `updated:7d`
</div>
<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.List className="max-h-[55vh] overflow-y-auto p-2 md:max-h-[320px]">
<Command.Empty className="py-8 text-center">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-border/70 bg-muted/35 text-muted-foreground">
<Search size={16} />
</div>
<p className="mt-2 text-sm font-medium text-foreground"></p>
<p className="mt-1 text-xs text-muted-foreground">使 `tag:` `type:` `updated:` </p>
</Command.Empty>
<Command.Group heading="文档">
{filteredPages.map((page) => (
<Command.Item
+222 -162
View File
@@ -8,6 +8,7 @@ import {
Sun,
Moon,
Upload,
Loader2,
FolderPlus,
FilePlus,
Settings,
@@ -20,13 +21,12 @@ import {
ChevronDown,
Layers,
PanelLeftOpen,
Plus,
SlidersHorizontal,
} from "lucide-react";
import { useTheme } from "next-themes";
import { TreeView } from "./sidebar/tree-view";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SearchCommand } from "@/components/search-command";
import { cn, getTagColor } from "@/lib/utils";
import { useSearchStore } from "@/lib/search-store";
import { useImport } from "@/components/import-context";
@@ -50,26 +50,20 @@ function RootDropZone() {
<div
ref={setNodeRef}
className={cn(
"flex min-h-[50px] flex-1 items-center justify-center rounded-md border-2 border-dashed border-transparent text-xs text-muted-foreground/0 transition-all",
"hover:text-muted-foreground/50",
isOver && "border-primary/20 bg-accent/30 text-primary/70"
"mt-2 flex min-h-[42px] items-center justify-center rounded-lg border border-dashed text-xs transition-all",
isOver
? "border-primary/40 bg-primary/10 text-primary"
: "border-transparent text-muted-foreground/0 hover:border-border/60 hover:text-muted-foreground/70"
)}
>
{isOver ? "移动到根目录" : ""}
{isOver ? "移动到根目录" : "拖拽到此处移到根目录"}
</div>
);
}
export function ResizableSidebar() {
const [width, setWidth] = useState(() => {
if (typeof window === "undefined") return 256;
const savedWidth = localStorage.getItem("sidebar-width");
return savedWidth ? parseInt(savedWidth, 10) : 256;
});
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("sidebar-collapsed") === "true";
});
const [width, setWidth] = useState(280);
const [isCollapsed, setIsCollapsed] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement>(null);
@@ -90,7 +84,7 @@ export function ResizableSidebar() {
(mouseMoveEvent: MouseEvent) => {
if (!isResizing) return;
const newWidth = mouseMoveEvent.clientX;
if (newWidth >= 220 && newWidth <= 480) {
if (newWidth >= 240 && newWidth <= 500) {
setWidth(newWidth);
}
},
@@ -110,7 +104,7 @@ export function ResizableSidebar() {
<aside
ref={sidebarRef}
className={cn(
"relative z-20 hidden h-screen flex-col border-r bg-secondary md:flex",
"group/sidebar relative z-20 hidden h-screen flex-col border-r bg-secondary/80 backdrop-blur-sm md:flex",
isResizing ? "transition-none" : "transition-[width] duration-300 ease-in-out",
isCollapsed && "w-[0px] border-none"
)}
@@ -142,7 +136,7 @@ export function ResizableSidebar() {
}
export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }) {
const { pages, fetchPages, addPage, isLoading, movePage, reorderPages } = useEditorStore();
const { pages, activePageId, fetchPages, addPage, isLoading, movePage, reorderPages } = useEditorStore();
const { setOpen, openSearchWithTag } = useSearchStore();
const { triggerImport, isImporting } = useImport();
const { theme, setTheme } = useTheme();
@@ -150,12 +144,60 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
const [mounted, setMounted] = React.useState(false);
const [isWorkspaceOpen, setIsWorkspaceOpen] = React.useState(true);
const [isTagsOpen, setIsTagsOpen] = React.useState(true);
const [isFilterOpen, setIsFilterOpen] = React.useState(false);
const [expandedMap, setExpandedMap] = React.useState<Record<string, boolean>>(() => {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem("noteai-tree-expanded");
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return {};
return parsed as Record<string, boolean>;
} catch {
return {};
}
});
React.useEffect(() => {
setMounted(true);
}, []);
React.useEffect(() => {
if (typeof window === "undefined") return;
try {
localStorage.setItem("noteai-tree-expanded", JSON.stringify(expandedMap));
} catch {
// ignore storage errors
}
}, [expandedMap]);
React.useEffect(() => {
if (!activePageId) return;
const current = pages.find((p) => p.id === activePageId);
if (!current) return;
const ancestors: string[] = [];
let cursor = current.parentId;
while (cursor) {
ancestors.push(cursor);
cursor = pages.find((p) => p.id === cursor)?.parentId ?? null;
}
if (ancestors.length === 0) return;
setExpandedMap((prev) => {
const next = { ...prev };
ancestors.forEach((id) => {
next[id] = true;
});
return next;
});
}, [activePageId, pages]);
const toggleExpand = React.useCallback((id: string) => {
setExpandedMap((prev) => ({ ...prev, [id]: !prev[id] }));
}, []);
const allTags = Array.from(new Set(pages.flatMap((p) => p.tags || []))).sort((a, b) => a.localeCompare(b));
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
@@ -232,86 +274,129 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
};
return (
<div className="flex h-full flex-col bg-secondary">
<div className="flex items-center justify-between p-4">
<div className="flex cursor-pointer items-center gap-2 font-semibold text-foreground/80 transition-colors hover:text-foreground">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-[10px] text-primary-foreground shadow-sm">AI</div>
<span className="tracking-tight">NoteAI</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => addPage(null, "file")}
className="ui-icon-btn h-8 w-8"
title="新建文件"
>
<FilePlus size={16} />
</button>
<button
onClick={() => addPage(null, "folder")}
className="ui-icon-btn h-8 w-8"
title="新建文件夹"
>
<FolderPlus size={16} />
</button>
{onCloseMobile && (
<button
onClick={onCloseMobile}
className="ui-icon-btn h-8 w-8 hover:text-destructive"
title="关闭菜单"
>
<PanelLeftClose size={16} />
<div className="flex h-full flex-col">
<div className="space-y-3 border-b border-border/60 bg-muted/[0.18] p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-[10px] font-bold text-primary-foreground">AI</div>
<div>
<div className="text-sm font-semibold tracking-tight text-foreground">NoteAI</div>
<div className="text-[10px] text-muted-foreground">Workspace</div>
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => addPage(null, "file")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件">
<span className="sr-only"></span>
<FilePlus size={15} />
</button>
)}
<button onClick={() => addPage(null, "folder")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件夹">
<span className="sr-only"></span>
<FolderPlus size={15} />
</button>
<button
onClick={() => {
setOpen(true);
handleItemClick();
}}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title="搜索文档 (Ctrl+K)"
aria-label="搜索文档"
>
<Search size={15} />
</button>
{onCloseMobile && (
<button
onClick={onCloseMobile}
className="ui-icon-btn h-10 w-10 hover:text-destructive md:h-8 md:w-8"
title="关闭菜单"
>
<span className="sr-only"></span>
<PanelLeftClose size={16} />
</button>
)}
</div>
</div>
</div>
<div className="mb-4 space-y-1 px-3">
<SearchCommand />
<button
onClick={() => {
setOpen(true);
handleItemClick();
}}
className="group flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/60"
>
<Search size={16} className="group-hover:text-foreground" />
<span className="group-hover:text-foreground"></span>
<kbd className="ui-kbd ml-auto">
<span className="text-xs">Ctrl</span>K
</kbd>
</button>
</div>
<div className="flex-1 space-y-1 overflow-y-auto px-2">
<button
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
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"
onClick={() => setIsFilterOpen((v) => !v)}
className="flex h-10 w-full items-center justify-between rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground md:h-8"
>
<span className="flex items-center gap-1.5">
{isWorkspaceOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Layers size={14} className="opacity-70" />
<SlidersHorizontal size={13} />
</span>
<Plus
size={14}
className="cursor-pointer rounded opacity-0 transition-opacity group-hover/workspace:opacity-100 hover:bg-muted/50"
onClick={(e) => {
e.stopPropagation();
addPage(null, "file");
setIsWorkspaceOpen(true);
}}
/>
{isFilterOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{isFilterOpen && (
<div className="rounded-lg border border-border/60 bg-background/40 p-2">
<div className="mb-1 flex items-center gap-1 text-[11px] text-muted-foreground">
<TagIcon size={12} />
</div>
<div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto pr-1">
{allTags.length > 0 ? (
allTags.map((tag) => {
const colors = getTagColor(tag);
return (
<button
key={tag}
onClick={() => {
openSearchWithTag(tag);
setOpen(true);
handleItemClick();
}}
className={cn(
"inline-flex items-center gap-1 rounded-[4px] border px-2 py-0.5 text-[10px] font-medium shadow-sm transition-transform duration-150 hover:scale-[1.02]",
colors.bg,
colors.text,
colors.border
)}
title={`按标签筛选:${tag}`}
>
<Hash size={10} className="opacity-70" />
{tag}
</button>
);
})
) : (
<span className="text-[10px] italic text-muted-foreground/70"></span>
)}
</div>
</div>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col p-2">
<button
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
className="mb-1 flex h-10 w-full items-center justify-between rounded-lg px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground md:h-8"
>
<span className="flex items-center gap-1.5">
<Layers size={13} className="opacity-80" />
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{pages.length}</span>
</span>
{isWorkspaceOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{isWorkspaceOpen && (
<>
{isLoading ? (
<div className="px-4 text-xs text-muted-foreground animate-pulse">...</div>
<div className="space-y-2 px-1 py-2">
<div className="rounded-lg border border-border/50 bg-background/35 p-2">
<div className="h-4 w-1/3 animate-pulse rounded bg-muted/60" />
<div className="mt-2 h-7 animate-pulse rounded-md bg-muted/55" />
<div className="mt-1.5 h-7 animate-pulse rounded-md bg-muted/45" />
<div className="mt-1.5 h-7 animate-pulse rounded-md bg-muted/35" />
</div>
</div>
) : pages.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
<p className="mb-2"></p>
<div className="ui-card mt-2 flex flex-col items-center gap-2 px-3 py-6 text-center text-xs text-muted-foreground">
<FileText size={18} className="text-muted-foreground/70" />
<p></p>
<button onClick={() => addPage(null, "file")} className="text-primary hover:underline">
</button>
</div>
) : (
@@ -321,17 +406,24 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
onDragStart={onDragStart}
onDragEnd={handleDragEnd}
>
<div onClick={handleItemClick} className="flex min-h-0 flex-1 flex-col">
<TreeView pages={pages} parentId={null} />
<div
onClick={handleItemClick}
className="min-h-0 flex-1 overflow-y-auto rounded-lg bg-background/15 p-0.5 pr-0.5"
>
<TreeView
pages={pages}
parentId={null}
expanded={expandedMap}
toggleExpand={toggleExpand}
folderFileCount={{}}
/>
<RootDropZone />
</div>
<DragOverlay>
{activeDragItem ? (
<div className="flex items-center gap-2 rounded-md border bg-popover px-2 py-1 text-sm opacity-90 shadow-lg">
<div className="flex items-center gap-2 rounded-md border bg-popover px-2 py-1 text-sm opacity-95 shadow-lg">
{activeDragItem.icon ? (
<span className="flex h-4 w-4 items-center justify-center text-[16px] leading-none 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.icon}</span>
) : activeDragItem.type === "folder" ? (
<Folder size={16} className="shrink-0 fill-indigo-500/20 text-indigo-500" />
) : (
@@ -347,78 +439,46 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
)}
</div>
<div className="border-t border-border/40 px-2 py-2">
<button
onClick={() => setIsTagsOpen(!isTagsOpen)}
className="group/tags mb-1 flex w-full items-center justify-between px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground/70 transition-colors hover:text-foreground"
>
<span className="flex items-center gap-1.5">
{isTagsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<TagIcon size={13} className="opacity-70" />
</span>
</button>
{isTagsOpen && (
<div className="flex flex-wrap gap-1 px-2">
{Array.from(new Set(pages.flatMap((p) => p.tags || []))).map((tag) => {
const colors = getTagColor(tag);
return (
<button
key={tag}
onClick={() => {
openSearchWithTag(tag);
handleItemClick();
}}
className={cn(
"inline-flex items-center gap-1 rounded-[4px] border px-2.5 py-1 text-[11px] font-medium shadow-sm transition-transform duration-200 hover:scale-105 active:scale-95",
colors.bg,
colors.text,
colors.border
)}
title={`按标签筛选:${tag}`}
>
<Hash size={12} className="opacity-70" />
{tag}
</button>
);
})}
{pages.flatMap((p) => p.tags || []).length === 0 && (
<span className="px-1 text-[10px] italic text-muted-foreground/60"></span>
)}
<div className="border-t border-border/60 bg-muted/[0.22] p-3 backdrop-blur-sm">
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-background/45 px-2 py-1">
<span className="px-1 text-[11px] text-muted-foreground"></span>
<div className="flex items-center gap-1">
<button
onClick={() => triggerImport(null)}
disabled={isImporting}
aria-busy={isImporting}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title={isImporting ? "导入中..." : "导入"}
>
<span className="sr-only">{isImporting ? "导入中" : "导入"}</span>
{isImporting ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
</button>
<Link
href="/settings"
onClick={handleItemClick}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title="设置"
aria-label="设置"
>
<Settings size={15} />
</Link>
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
title={mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}
>
<span className="sr-only">{mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}</span>
{mounted ? (theme === "dark" ? <Sun size={15} /> : <Moon size={15} />) : <Moon size={15} />}
</button>
<button
onClick={handleLogout}
className="ui-icon-btn h-10 w-10 hover:text-destructive md:h-8 md:w-8"
title="退出登录"
>
<span className="sr-only">退</span>
<LogOut size={15} />
</button>
</div>
)}
</div>
<div className="space-y-1 border-t bg-muted/20 p-3 backdrop-blur-sm">
<button
onClick={() => triggerImport(null)}
disabled={isImporting}
className="ui-btn-secondary w-full justify-start border border-transparent text-muted-foreground hover:border-border hover:text-foreground disabled:opacity-50"
>
<Upload size={16} />
<span>{isImporting ? "导入中..." : "导入文档"}</span>
</button>
<div className="flex items-center gap-1">
<Link
href="/settings"
onClick={handleItemClick}
className="ui-btn-secondary flex-1 justify-start border border-transparent text-muted-foreground hover:border-border hover:text-foreground"
>
<Settings size={16} />
<span></span>
</Link>
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="ui-icon-btn"
title={mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}
>
{mounted ? (theme === "dark" ? <Sun size={16} /> : <Moon size={16} />) : <Moon size={16} />}
</button>
<button onClick={handleLogout} className="ui-icon-btn hover:text-destructive" title="退出登录">
<LogOut size={16} />
</button>
</div>
</div>
</div>
+83 -34
View File
@@ -1,6 +1,5 @@
"use client";
import { useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useRouter, usePathname } from "next/navigation";
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
@@ -27,7 +26,43 @@ import { useConfirm } from "@/components/confirm-provider";
interface TreeViewProps {
pages: Page[];
parentId: string | null;
expanded: Record<string, boolean>;
toggleExpand: (id: string) => void;
level?: number;
folderFileCount: Record<string, number>;
}
function buildFolderFileCount(pages: Page[]): Record<string, number> {
const byParent = new Map<string | null, Page[]>();
for (const page of pages) {
const siblings = byParent.get(page.parentId) || [];
siblings.push(page);
byParent.set(page.parentId, siblings);
}
const counts: Record<string, number> = {};
const dfs = (folderId: string): number => {
const children = byParent.get(folderId) || [];
let total = 0;
for (const child of children) {
if (child.type === "file") {
total += 1;
} else {
total += dfs(child.id);
}
}
counts[folderId] = total;
return total;
};
for (const page of pages) {
if (page.type === "folder") {
dfs(page.id);
}
}
return counts;
}
function TreeNode({
@@ -36,12 +71,14 @@ function TreeNode({
level,
expanded,
toggleExpand,
folderFileCount,
}: {
node: Page;
pages: Page[];
level: number;
expanded: Record<string, boolean>;
toggleExpand: (id: string) => void;
folderFileCount: Record<string, number>;
}) {
const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
const router = useRouter();
@@ -52,6 +89,7 @@ function TreeNode({
const isFolder = node.type === "folder";
const hasChildren = pages.some((p) => p.parentId === node.id);
const isExpanded = expanded[node.id];
const isActive = activePageId === node.id;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: node.id,
@@ -59,7 +97,7 @@ function TreeNode({
});
const style = {
paddingLeft: `${level * 12 + 8}px`,
paddingLeft: `${level * 12 + 6}px`,
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
@@ -69,17 +107,19 @@ function TreeNode({
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
<div
className={cn(
"group flex items-center justify-between px-2 py-1 text-sm rounded-md transition-colors cursor-pointer select-none border border-transparent",
activePageId === node.id
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
"group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[13px] transition-colors cursor-pointer select-none md:h-8",
isActive
? "border-border/60 bg-accent/75 text-accent-foreground"
: "text-muted-foreground hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
)}
onClick={() => {
setActivePageId(node.id);
if (pathname !== "/") router.push("/");
}}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{isActive && <span className="absolute left-0 top-[8px] h-5 w-0.5 rounded-r bg-primary/90" />}
<div className="grid min-w-0 flex-1 grid-cols-[1.75rem_1.25rem_minmax(0,1fr)] items-center gap-1.5">
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -87,36 +127,42 @@ function TreeNode({
toggleExpand(node.id);
}}
className={cn(
"p-0.5 rounded-sm hover:bg-muted-foreground/20 transition-colors",
"flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted-foreground/15 hover:text-foreground md:h-6 md:w-6",
!hasChildren && !isFolder && "invisible"
)}
>
<ChevronRight
size={16}
className={cn("transition-transform shrink-0 text-muted-foreground/50", isExpanded && "rotate-90")}
/>
<ChevronRight size={14} className={cn("shrink-0 text-muted-foreground/60 transition-transform", isExpanded && "rotate-90")} />
</button>
{node.icon ? (
<span className="text-[16px] leading-none shrink-0 w-4 h-4 flex items-center justify-center grayscale-[0.2]">{node.icon}</span>
) : isFolder ? (
isExpanded ? (
<FolderOpen size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
<span className="flex h-5 w-5 items-center justify-center">
{node.icon ? (
<span className="flex h-5 w-5 items-center justify-center text-[16px] leading-none grayscale-[0.2]">{node.icon}</span>
) : isFolder ? (
isExpanded ? (
<FolderOpen size={15} className="fill-indigo-500/20 text-indigo-500" />
) : (
<Folder size={15} className="fill-indigo-500/20 text-indigo-500" />
)
) : (
<Folder size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
)
) : (
<FileText size={16} className="text-muted-foreground/70 shrink-0" />
)}
<span className="truncate text-[14px] leading-none mb-0.5">{node.title}</span>
<FileText size={15} className="text-muted-foreground/70" />
)}
</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{node.title || "无标题"}</span>
{isFolder && (
<span className="inline-flex min-w-[1.2rem] shrink-0 items-center justify-center rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{folderFileCount[node.id] ?? 0}
</span>
)}
</span>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
onPointerDown={(e) => e.stopPropagation()}
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 p-1 hover:bg-muted-foreground/20 rounded text-muted-foreground transition-opacity"
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/75 transition-opacity hover:bg-muted-foreground/15 hover:text-foreground opacity-100 md:h-6 md:w-6 lg:opacity-0 lg:group-hover:opacity-100"
>
<MoreHorizontal size={14} />
<MoreHorizontal size={13} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
@@ -173,7 +219,7 @@ function TreeNode({
<Download size={14} />
<span></span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-muted my-1" />
<DropdownMenu.Separator className="my-1 h-px bg-muted" />
<DropdownMenu.Item
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) => {
@@ -199,23 +245,25 @@ function TreeNode({
</div>
{(isExpanded || hasChildren) && isExpanded && (
<TreeView pages={pages} parentId={node.id} level={level + 1} />
<TreeView
pages={pages}
parentId={node.id}
level={level + 1}
expanded={expanded}
toggleExpand={toggleExpand}
folderFileCount={folderFileCount}
/>
)}
</div>
);
}
export function TreeView({ pages, parentId, level = 0 }: TreeViewProps) {
export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, folderFileCount }: TreeViewProps) {
const computedFolderFileCount = level === 0 ? buildFolderFileCount(pages) : folderFileCount;
const nodes = pages
.filter((p) => p.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = (id: string) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
if (nodes.length === 0) return null;
return (
@@ -229,6 +277,7 @@ export function TreeView({ pages, parentId, level = 0 }: TreeViewProps) {
level={level}
expanded={expanded}
toggleExpand={toggleExpand}
folderFileCount={computedFolderFileCount}
/>
))}
</div>
+2 -1
View File
@@ -109,7 +109,8 @@ export const useEditorStore = create<EditorState>()(
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error(`Update failed: ${res.status}`);
const errorText = await res.text().catch(() => "");
throw new Error(`Update failed: ${res.status}${errorText ? ` - ${errorText}` : ""}`);
}
} catch (e) {
console.error("Failed to update page", e);