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