对移动端的UI进行了重构
This commit is contained in:
Binary file not shown.
@@ -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
@@ -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 */
|
||||
}
|
||||
|
||||
|
||||
+79
-40
@@ -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="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">
|
||||
{isHistoryOpen && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{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">
|
||||
<div className="truncate text-xs text-foreground">
|
||||
{snapshot.title || "无标题"}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
<>
|
||||
<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">
|
||||
{localHistory[effectiveHistoryIndex]?.title || "无标题"}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -401,15 +438,17 @@ 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-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 text-center">
|
||||
<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>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
+33
-16
@@ -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 () => {
|
||||
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 = "";
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 助手"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+183
-123
@@ -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 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-8 w-8"
|
||||
title="新建文件"
|
||||
>
|
||||
<FilePlus size={16} />
|
||||
<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={() => addPage(null, "folder")}
|
||||
className="ui-icon-btn h-8 w-8"
|
||||
title="新建文件夹"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
handleItemClick();
|
||||
}}
|
||||
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
|
||||
title="搜索文档 (Ctrl+K)"
|
||||
aria-label="搜索文档"
|
||||
>
|
||||
<FolderPlus size={16} />
|
||||
<Search size={15} />
|
||||
</button>
|
||||
{onCloseMobile && (
|
||||
<button
|
||||
onClick={onCloseMobile}
|
||||
className="ui-icon-btn h-8 w-8 hover:text-destructive"
|
||||
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 className="mb-4 space-y-1 px-3">
|
||||
<SearchCommand />
|
||||
<button
|
||||
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">
|
||||
<SlidersHorizontal size={13} />
|
||||
筛选器
|
||||
</span>
|
||||
{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="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"
|
||||
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}`}
|
||||
>
|
||||
<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>
|
||||
<Hash size={10} className="opacity-70" />
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-[10px] italic text-muted-foreground/70">暂无标签</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1 overflow-y-auto px-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col p-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"
|
||||
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">
|
||||
{isWorkspaceOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<Layers size={14} className="opacity-70" />
|
||||
我的工作区
|
||||
<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>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
{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,80 +439,48 @@ 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 border-t bg-muted/20 p-3 backdrop-blur-sm">
|
||||
<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}
|
||||
className="ui-btn-secondary w-full justify-start border border-transparent text-muted-foreground hover:border-border hover:text-foreground disabled:opacity-50"
|
||||
aria-busy={isImporting}
|
||||
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
|
||||
title={isImporting ? "导入中..." : "导入"}
|
||||
>
|
||||
<Upload size={16} />
|
||||
<span>{isImporting ? "导入中..." : "导入文档"}</span>
|
||||
<span className="sr-only">{isImporting ? "导入中" : "导入"}</span>
|
||||
{isImporting ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
|
||||
</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"
|
||||
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
|
||||
title="设置"
|
||||
aria-label="设置"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>设置</span>
|
||||
<Settings size={15} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="ui-icon-btn"
|
||||
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
|
||||
title={mounted ? (theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}
|
||||
>
|
||||
{mounted ? (theme === "dark" ? <Sun size={16} /> : <Moon size={16} />) : <Moon size={16} />}
|
||||
<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 hover:text-destructive" title="退出登录">
|
||||
<LogOut size={16} />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<span className="flex h-5 w-5 items-center justify-center">
|
||||
{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>
|
||||
<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={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
|
||||
<FolderOpen size={15} className="fill-indigo-500/20 text-indigo-500" />
|
||||
) : (
|
||||
<Folder size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
|
||||
<Folder size={15} className="fill-indigo-500/20 text-indigo-500" />
|
||||
)
|
||||
) : (
|
||||
<FileText size={16} className="text-muted-foreground/70 shrink-0" />
|
||||
<FileText size={15} className="text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate text-[14px] leading-none mb-0.5">{node.title}</span>
|
||||
</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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user