对移动端的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 });
|
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
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-40
@@ -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">
|
||||||
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<History size={14} className="text-muted-foreground" />
|
<History size={14} className="text-muted-foreground" />
|
||||||
<h3 className="text-sm font-semibold text-foreground">本地历史版本</h3>
|
<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>
|
</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 && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
{localHistory.length > 0 ? (
|
{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="rounded-lg border border-border/60 bg-muted/20 p-2">
|
||||||
<div className="min-w-0">
|
<label className="mb-1 block text-[11px] text-muted-foreground">选择要恢复的版本</label>
|
||||||
<div className="truncate text-xs text-foreground">
|
<select
|
||||||
{snapshot.title || "无标题"}
|
value={selectedHistoryIndex}
|
||||||
</div>
|
onChange={(e) => setSelectedHistoryIndex(parseInt(e.target.value, 10))}
|
||||||
<div className="text-[11px] text-muted-foreground">
|
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", {
|
{new Date(snapshot.timestamp).toLocaleString("zh-CN", {
|
||||||
timeZone: timezone || "Asia/Shanghai",
|
timeZone: timezone || "Asia/Shanghai",
|
||||||
hour12: false,
|
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>
|
||||||
</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,15 +438,17 @@ 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-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">
|
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
|
||||||
<span className="text-4xl">🧠</span>
|
<span className="text-4xl">🧠</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-center">
|
<div className="space-y-1">
|
||||||
<h3 className="text-lg font-semibold text-foreground">欢迎使用 NoteAI</h3>
|
<h3 className="text-lg font-semibold text-foreground">欢迎使用 NoteAI</h3>
|
||||||
<p className="text-sm text-muted-foreground/90">在左侧选择一个页面,或新建文档开始写作。</p>
|
<p className="text-sm text-muted-foreground/90">在左侧选择一个页面,或新建文档开始写作。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+33
-16
@@ -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 () => {
|
||||||
|
if (isExporting || isRestoring) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
const { pages } = await import("@/lib/store").then((m) => m.useEditorStore.getState());
|
const { pages } = await import("@/lib/store").then((m) => m.useEditorStore.getState());
|
||||||
const { exportAllPagesAsZip } = await import("@/lib/export");
|
const { exportAllPagesAsZip } = await import("@/lib/export");
|
||||||
await exportAllPagesAsZip(pages);
|
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 = "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 助手"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+183
-123
@@ -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"
|
||||||
)}
|
)}
|
||||||
@@ -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 className="text-sm font-semibold tracking-tight text-foreground">NoteAI</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">Workspace</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button onClick={() => addPage(null, "file")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件">
|
||||||
onClick={() => addPage(null, "file")}
|
<span className="sr-only">新建文件</span>
|
||||||
className="ui-icon-btn h-8 w-8"
|
<FilePlus size={15} />
|
||||||
title="新建文件"
|
</button>
|
||||||
>
|
<button onClick={() => addPage(null, "folder")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件夹">
|
||||||
<FilePlus size={16} />
|
<span className="sr-only">新建文件夹</span>
|
||||||
|
<FolderPlus size={15} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => addPage(null, "folder")}
|
onClick={() => {
|
||||||
className="ui-icon-btn h-8 w-8"
|
setOpen(true);
|
||||||
title="新建文件夹"
|
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>
|
</button>
|
||||||
{onCloseMobile && (
|
{onCloseMobile && (
|
||||||
<button
|
<button
|
||||||
onClick={onCloseMobile}
|
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="关闭菜单"
|
title="关闭菜单"
|
||||||
>
|
>
|
||||||
|
<span className="sr-only">关闭菜单</span>
|
||||||
<PanelLeftClose size={16} />
|
<PanelLeftClose size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 space-y-1 px-3">
|
|
||||||
<SearchCommand />
|
|
||||||
<button
|
<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={() => {
|
onClick={() => {
|
||||||
|
openSearchWithTag(tag);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
handleItemClick();
|
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" />
|
<Hash size={10} className="opacity-70" />
|
||||||
<span className="group-hover:text-foreground">全局搜索</span>
|
{tag}
|
||||||
<kbd className="ui-kbd ml-auto">
|
|
||||||
<span className="text-xs">Ctrl</span>K
|
|
||||||
</kbd>
|
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] italic text-muted-foreground/70">暂无标签</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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
|
<button
|
||||||
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
|
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">
|
<span className="flex items-center gap-1.5">
|
||||||
{isWorkspaceOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
<Layers size={13} className="opacity-80" />
|
||||||
<Layers size={14} className="opacity-70" />
|
文档
|
||||||
我的工作区
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{pages.length}</span>
|
||||||
</span>
|
</span>
|
||||||
<Plus
|
{isWorkspaceOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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,80 +439,48 @@ 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">
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => triggerImport(null)}
|
onClick={() => triggerImport(null)}
|
||||||
disabled={isImporting}
|
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 className="sr-only">{isImporting ? "导入中" : "导入"}</span>
|
||||||
<span>{isImporting ? "导入中..." : "导入文档"}</span>
|
{isImporting ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={handleItemClick}
|
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} />
|
<Settings size={15} />
|
||||||
<span>设置</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
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" ? "切换到明亮模式" : "切换到暗黑模式") : "切换主题"}
|
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>
|
||||||
<button onClick={handleLogout} className="ui-icon-btn hover:text-destructive" title="退出登录">
|
<button
|
||||||
<LogOut size={16} />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center">
|
||||||
{node.icon ? (
|
{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 ? (
|
) : isFolder ? (
|
||||||
isExpanded ? (
|
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>
|
</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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user