Files
mnote/wolai-frontend/src/components/sidebar/sidebar.tsx
T

1506 lines
52 KiB
TypeScript
Raw Normal View History

2025-11-23 10:55:04 +08:00
"use client";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import {
ArrowRightLeft,
ArrowUpRight,
Bell,
ChevronRight,
Copy,
Dice5,
Edit3,
GitMerge,
Globe,
Hash,
LayoutGrid,
Library,
Link as LinkIcon,
MoreHorizontal,
PanelRightOpen,
PenSquare,
Plus,
Search as SearchIcon,
Share2,
Shield,
Star,
Trash2,
Upload,
Users,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import type { DocumentNode } from "@/lib/documents";
import { buildDocumentTree } from "@/lib/documents";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
import { supabaseBrowser } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette";
2025-12-27 20:23:35 +08:00
import { FileTree } from "@/components/sidebar/file-tree";
import type { MediaAsset } from "@/types/media";
2026-01-02 07:25:50 +08:00
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
2025-11-23 10:55:04 +08:00
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
{ id: "graph", icon: Share2, label: "关系图" },
{ id: "import", icon: Upload, label: "导入" },
{ id: "members", icon: Users, label: "成员" },
{ id: "inbox", icon: Bell, label: "消息箱" },
{ id: "quick-note", icon: PenSquare, label: "今日速记" },
{ id: "lucky", icon: Dice5, label: "手气不错" },
{ id: "more", icon: MoreHorizontal, label: "更多" },
] as const;
const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNode> = {
starred: <Star className="h-4 w-4 text-[#f5a623]" />,
public: <Globe className="h-4 w-4 text-[#60a5fa]" />,
shared: <Shield className="h-4 w-4 text-[#9b87f5]" />,
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
};
interface SidebarProps {
initialData: SidebarInitialData;
}
interface ContextMenuState {
node: DocumentNode;
x: number;
y: number;
}
export function Sidebar({ initialData }: SidebarProps) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
useSidebarStore();
2025-12-27 20:23:35 +08:00
const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
2025-11-23 10:55:04 +08:00
const sidebarQuery = useSidebarData(initialData);
const sidebarData = sidebarQuery.data ?? initialData;
const segments = useSelectedLayoutSegments();
const router = useRouter();
const activeId = segments?.[1] ?? "";
const [tree, setTree] = useState<DocumentNode[]>(() => buildDocumentTree(sidebarData.documents));
const [filter, setFilter] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
const [trashOpen, setTrashOpen] = useState(false);
const [trashSearch, setTrashSearch] = useState("");
const [emptyingTrash, setEmptyingTrash] = useState(false);
2026-01-02 07:25:50 +08:00
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
const [mindmapDocs, setMindmapDocs] = useState<string[]>(sidebarData.mindmapDocs ?? []);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
const [lastSelectedAssetId, setLastSelectedAssetId] = useState<string | null>(null);
2025-11-23 10:55:04 +08:00
const workspaceMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setTree(() => {
const nextTree = buildDocumentTree(sidebarData.documents);
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
return nextTree;
});
}, [sidebarData.documents]);
2026-01-02 07:25:50 +08:00
useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []);
}, [sidebarData.mediaAssets]);
useEffect(() => {
setMindmapDocs(sidebarData.mindmapDocs ?? []);
}, [sidebarData.mindmapDocs]);
useEffect(() => {
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
}, [mediaAssets, sidebarData.documents]);
2025-11-23 10:55:04 +08:00
useEffect(() => {
setOpen(false);
}, [activeId, setOpen]);
2026-01-02 07:25:50 +08:00
useEffect(() => {
const handler = (event: Event) => {
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
const asset = custom.detail?.asset as MediaAsset | undefined;
if (asset?.id) {
setMediaAssets((prev) => {
if (prev.find((item) => item.id === asset.id)) return prev;
return [asset, ...prev];
});
}
void sidebarQuery.refetch();
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
return () => {
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
};
}, [sidebarQuery]);
2025-11-23 10:55:04 +08:00
const refreshTree = useCallback(async () => {
await sidebarQuery.refetch();
}, [sidebarQuery]);
useEffect(() => {
const channel = supabaseBrowser
.channel("documents-feed")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "documents" },
() => {
void refreshTree();
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [refreshTree]);
2026-01-02 07:25:50 +08:00
useEffect(() => {
const channel = supabaseBrowser
.channel("media-assets-feed")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "media_assets",
filter: `workspace_id=eq.${sidebarData.activeWorkspaceId}`,
},
() => {
void sidebarQuery.refetch();
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [sidebarData.activeWorkspaceId, sidebarQuery]);
2025-11-23 10:55:04 +08:00
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
const sharedNodes = useMemo(() => sections.find((section) => section.id === "shared")?.nodes ?? [], [sections]);
const templateNodes = useMemo(() => sections.find((section) => section.id === "templates")?.nodes ?? [], [sections]);
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
const filteredPrivateTree = useMemo(
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
[filter, privateTree],
);
const flattenedPrivate = useMemo(
() => flattenDocumentTree(privateTree, expanded),
[privateTree, expanded],
);
const filteredTrash = useMemo(() => {
const keyword = trashSearch.trim().toLowerCase();
if (!keyword) {
return sidebarData.trashedDocuments;
}
return sidebarData.trashedDocuments.filter((item) =>
(item.title ?? "无标题").toLowerCase().includes(keyword),
);
}, [sidebarData.trashedDocuments, trashSearch]);
2025-12-27 20:23:35 +08:00
const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {};
2026-01-02 07:25:50 +08:00
const assets = mediaAssets;
2025-12-27 20:23:35 +08:00
assets.forEach((asset) => {
if (!map[asset.document_id]) {
map[asset.document_id] = [];
}
map[asset.document_id].push(asset);
});
2026-01-02 07:25:50 +08:00
if (mindmapDocs.length > 0) {
mindmapDocs.forEach((docId) => {
const doc = sidebarData.documents.find((d) => d.id === docId);
const workspaceId = doc?.workspace_id ?? "";
if (!map[docId]) {
map[docId] = [];
}
// 避免重复插入
const alreadyExists = map[docId].some(
(asset) => asset.asset_type === "mindmap",
);
if (!alreadyExists) {
map[docId].push({
id: `mindmap-${docId}`,
workspace_id: workspaceId,
document_id: docId,
asset_type: "mindmap",
file_url: `/documents/${docId}`,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: "mindmap.json",
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "",
updated_at: "",
});
}
});
}
2025-12-27 20:23:35 +08:00
return map;
2026-01-02 07:25:50 +08:00
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
const assetOrder = useMemo(() => {
const list: string[] = [];
Object.values(assetsByDoc).forEach((arr) => {
arr.forEach((asset) => list.push(asset.id));
});
return list;
}, [assetsByDoc]);
const assetIndexMap = useMemo(() => {
const map = new Map<string, number>();
assetOrder.forEach((id, idx) => map.set(id, idx));
return map;
}, [assetOrder]);
2025-12-27 20:23:35 +08:00
2025-11-23 10:55:04 +08:00
const activeWorkspace =
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
sidebarData.workspaces[0];
const handleOpenDocument = useCallback(
(documentId: string, mode: "main" | "sidebar") => {
2026-01-02 07:25:50 +08:00
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
2025-11-23 10:55:04 +08:00
const targetPath = `/documents/${documentId}`;
if (mode === "main") {
router.push(targetPath);
setOpen(false);
return;
}
if (typeof window !== "undefined") {
const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`;
window.open(sidebarUrl, "_blank", "noopener,noreferrer");
}
},
[router, setOpen],
);
const handleCopyLink = useCallback(async (node: DocumentNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制");
}, []);
const handleCopyReference = useCallback(async (node: DocumentNode, mode: "inline" | "embed") => {
const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`;
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
}, []);
const handleCopyId = useCallback(async (node: DocumentNode) => {
await copyText(node.id, "页面 ID 已复制");
}, []);
const handleDuplicateDocument = useCallback(
async (node: DocumentNode) => {
const response = await fetch("/api/documents/duplicate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId: node.id }),
});
if (!response.ok) {
window.alert("复制失败,请稍后再试");
return;
}
await refreshTree();
},
[refreshTree],
);
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
if (typeof window === "undefined") {
return;
}
const target = window.prompt("输入希望嵌入到的页面 ID");
if (!target) {
return;
}
const response = await fetch("/api/documents/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: node.id, targetId: target.trim() }),
});
if (!response.ok) {
window.alert("嵌入失败,请检查目标页面 ID");
return;
}
window.alert("已在目标页面末尾插入引用块");
}, []);
const toggleExpand = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
2025-12-27 20:23:35 +08:00
const handleOpenAsset = useCallback((asset: MediaAsset) => {
2026-01-02 07:25:50 +08:00
if (asset.asset_type === "mindmap") {
router.push(`/documents/${asset.document_id}`);
setOpen(false);
return;
}
2025-12-27 20:23:35 +08:00
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的文件链接");
return;
}
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
2026-01-02 07:25:50 +08:00
}, [router, setOpen]);
const handleAssetContextMenu = useCallback(
(event: React.MouseEvent, asset: MediaAsset) => {
event.preventDefault();
if (!selectedAssetIds.has(asset.id)) {
setSelectedAssetIds(new Set([asset.id]));
setLastSelectedAssetId(asset.id);
}
setAssetMenu({ asset, x: event.clientX, y: event.clientY });
},
[selectedAssetIds],
);
const handleSelectAsset = useCallback(
(asset: MediaAsset, event?: { type?: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
const evtType = event?.type ?? "";
if (evtType === "contextmenu" && selectedAssetIds.has(asset.id)) {
setLastSelectedAssetId(asset.id);
return;
}
setSelectedAssetIds((prev) => {
let next = new Set(prev);
const withShift = Boolean(event?.shiftKey) && lastSelectedAssetId && assetIndexMap.has(lastSelectedAssetId);
if (withShift) {
const start = assetIndexMap.get(lastSelectedAssetId!) ?? 0;
const end = assetIndexMap.get(asset.id) ?? start;
const [lo, hi] = start < end ? [start, end] : [end, start];
const idsInRange = assetOrder.slice(lo, hi + 1);
next = new Set([...prev, ...idsInRange]);
} else if (event?.metaKey || event?.ctrlKey) {
if (next.has(asset.id)) {
next.delete(asset.id);
} else {
next.add(asset.id);
}
} else {
next = new Set([asset.id]);
}
return next;
});
setLastSelectedAssetId(asset.id);
},
[assetIndexMap, assetOrder, lastSelectedAssetId, selectedAssetIds],
);
const toggleAssetCheckbox = useCallback((assetId: string) => {
setSelectedAssetIds((prev) => {
const next = new Set(prev);
if (next.has(assetId)) {
next.delete(assetId);
} else {
next.add(assetId);
}
return next;
});
setLastSelectedAssetId(assetId);
2025-12-27 20:23:35 +08:00
}, []);
2026-01-02 07:25:50 +08:00
const selectOnlyAsset = useCallback((assetId: string) => {
setSelectedAssetIds(new Set([assetId]));
setLastSelectedAssetId(assetId);
}, []);
const handleCopyAssetLink = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
await copyText(buildDocumentUrl(asset.document_id), "页面链接已复制");
return;
}
const url = asset.signed_url ?? asset.file_url ?? "";
if (!url) {
window.alert("暂无可用的文件链接");
return;
}
await copyText(url, "附件链接已复制");
},
[],
);
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
const path =
asset.asset_type === "mindmap"
? `documents/${asset.document_id}/mindmap.json`
: asset.storage_path || asset.file_url || asset.file_name || "附件";
await copyText(path, "存储路径已复制");
}, []);
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${asset.document_id}`);
if (!resp.ok) {
window.alert("下载失败");
return;
}
const payload = await resp.json().catch(() => null);
const data = payload?.data ?? {};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "mindmap.json";
a.click();
URL.revokeObjectURL(url);
return;
}
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的下载链接");
return;
}
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const handleRenameAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图暂不支持重命名");
return;
}
const input = window.prompt("输入新文件名", asset.file_name ?? "");
if (!input || !input.trim()) return;
const newName = input.trim();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "重命名失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
},
[sidebarQuery],
);
const handleMoveAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图文件无需移动,请在页面中直接编辑");
return;
}
const target = window.prompt("输入目标页面 ID", asset.document_id);
if (!target || !target.trim()) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: [asset.id],
targetDocumentId: target.trim(),
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
emitAssetsChanged(target.trim());
},
[sidebarQuery],
);
const handleDeleteAssets = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const target =
assetHint ??
mediaAssets.find((item) => assetIds.includes(item.id)) ??
null;
if (target?.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${target.document_id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
setMindmapDocs((prev) => prev.filter((id) => id !== target.document_id));
setAssetMenu(null);
emitAssetsChanged(target.document_id, undefined, undefined, true);
return;
}
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return;
}
setMediaAssets((prev) => prev.filter((item) => !assetIds.includes(item.id)));
setAssetMenu(null);
emitAssetsChanged(target?.document_id, undefined, assetIds);
},
[mediaAssets, sidebarQuery],
);
2025-11-23 10:55:04 +08:00
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const targetWidth = Math.min(420, Math.max(240, startWidth + delta));
setWidth(targetWidth);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[setWidth, width],
);
const handleCreate = useCallback(
async (parentId: string | null) => {
const response = await fetch("/api/documents/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parentId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "新建页面失败,请稍后再试");
return;
}
const payload = (await response.json()) as DocumentNode;
const nextNode: DocumentNode = {
...payload,
access_scope: payload.access_scope ?? "private",
is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at,
title: payload.title ?? "无标题",
children: [],
};
setTree((prev) => insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode));
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) {
next.add(parentId);
}
if (!parentId) {
next.add(nextNode.id);
}
return next;
});
await refreshTree();
router.push(`/documents/${nextNode.id}`);
},
[refreshTree, router],
);
const handleRename = useCallback(
async (documentId: string, currentTitle: string | null) => {
const title = window.prompt("输入新的标题", currentTitle ?? "无标题") ?? "";
if (!title.trim()) {
return;
}
await fetch("/api/documents/title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, title: title.trim() }),
});
await refreshTree();
},
[refreshTree],
);
const moveLocalNode = useCallback((currentTree: DocumentNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) {
return currentTree;
}
const next = insertNode(withoutTarget, parentId, index, removed);
return next;
}, []);
const handleMove = useCallback(
async (documentId: string, parentId: string | null, index: number) => {
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
if (parentId) {
setExpanded((prev) => new Set(prev).add(parentId));
}
await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId,
parentId,
position: index,
}),
});
await refreshTree();
},
[moveLocalNode, refreshTree, setExpanded],
);
const handleMovePrompt = useCallback(
async (node: DocumentNode) => {
if (typeof window === "undefined") {
return;
}
const target = window.prompt("输入目标父页面 ID(留空表示移动到根目录)", node.parent_id ?? "");
if (target === null) {
return;
}
const trimmed = target.trim();
await handleMove(node.id, trimmed.length > 0 ? trimmed : null, 0);
},
[handleMove],
);
const handleDelete = useCallback(
async (documentId: string) => {
await fetch("/api/documents/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
await refreshTree();
2026-01-02 07:25:50 +08:00
emitDocumentsChanged(documentId);
if (activeId === documentId) {
router.push("/");
}
2025-11-23 10:55:04 +08:00
},
2026-01-02 07:25:50 +08:00
[activeId, refreshTree, router],
2025-11-23 10:55:04 +08:00
);
const handleConvertToChild = useCallback(
async (documentId: string) => {
const flat = flattenedPrivate;
const currentIndex = flat.findIndex((item) => item.node.id === documentId);
if (currentIndex <= 0) {
return;
}
const targetParentId = flat[currentIndex - 1].node.id;
await handleMove(documentId, targetParentId, flat[currentIndex - 1].node.children.length);
},
[flattenedPrivate, handleMove],
);
const confirmTrashAction = useCallback(
(message: string) => {
if (!trashConfirm) {
return true;
}
return window.confirm(message);
},
[trashConfirm],
);
const handleRestoreFromTrash = useCallback(
async (documentId: string) => {
if (!confirmTrashAction("确认恢复该页面吗?")) {
return;
}
await fetch("/api/documents/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handlePurgeFromTrash = useCallback(
async (documentId: string) => {
if (!confirmTrashAction("彻底删除后将无法找回,是否继续?")) {
return;
}
await fetch("/api/documents/purge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handleEmptyTrash = useCallback(async () => {
if (!sidebarData.activeWorkspaceId) {
window.alert("暂无可清空的工作空间");
return;
}
if (!confirmTrashAction("清空垃圾桶后将无法恢复,是否继续?")) {
return;
}
setEmptyingTrash(true);
try {
const response = await fetch("/api/documents/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "清空垃圾桶失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
} finally {
setEmptyingTrash(false);
}
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
const handleWorkspaceSwitch = useCallback(
async (workspaceId: string) => {
if (workspaceId === sidebarData.activeWorkspaceId) {
setWorkspaceMenuOpen(false);
return;
}
await fetch("/api/workspaces/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId }),
});
setWorkspaceMenuOpen(false);
await sidebarQuery.refetch();
},
[sidebarData.activeWorkspaceId, sidebarQuery],
);
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({
node,
x: event.clientX,
y: event.clientY,
});
}, []);
const handleTopButtonClick = useCallback(
(buttonId: (typeof TOP_BUTTONS)[number]["id"]) => {
if (buttonId === "search") {
openSearchPalette();
return;
}
window.alert("该功能即将上线,敬请期待");
},
[openSearchPalette],
);
useEffect(() => {
if (!contextMenu) {
return;
}
const closeMenu = () => setContextMenu(null);
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, [contextMenu]);
useEffect(() => {
if (!workspaceMenuOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
workspaceMenuRef.current &&
!workspaceMenuRef.current.contains(event.target as Node)
) {
setWorkspaceMenuOpen(false);
}
};
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
}, [workspaceMenuOpen]);
const sidebarBody = (
<div className="flex h-full flex-col">
<div className="border-b border-[#f1f1f1] p-3">
<div className="text-xs text-gray-400">当前工作空间</div>
<div className="mt-1 flex items-center justify-between">
<div>
<div className="text-base font-semibold text-gray-900">{activeWorkspace?.name ?? "我的空间"}</div>
<div className="text-xs text-gray-500">{activeWorkspace?.type === "team" ? "团队空间" : "个人空间"}</div>
</div>
<div className="relative" ref={workspaceMenuRef}>
<button
type="button"
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={(event) => {
event.preventDefault();
setWorkspaceMenuOpen((prev) => !prev);
}}
>
切换
</button>
{workspaceMenuOpen && (
<div className="absolute right-0 z-20 mt-2 w-56 rounded-md border border-[#eaeaea] bg-white shadow-lg">
{sidebarData.workspaces.map((workspace) => (
<button
type="button"
key={workspace.id}
className={cn(
"flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50",
workspace.id === activeWorkspace?.id && "bg-[#f5f7fb]",
)}
onClick={() => void handleWorkspaceSwitch(workspace.id)}
>
<span>{workspace.name}</span>
{workspace.id === activeWorkspace?.id ? (
<span className="text-xs text-[#2563eb]">当前</span>
) : (
<span className="text-xs text-gray-400">{workspace.memberCount} </span>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-2 border-b border-[#f1f1f1] p-3">
{TOP_BUTTONS.map((button) => (
<button
key={button.id}
type="button"
title={button.label}
aria-label={button.label}
className="flex h-12 items-center justify-center rounded-md border border-transparent text-gray-500 hover:border-[#d6e3ff] hover:bg-[#f5f7fb] hover:text-[#2563eb]"
onClick={() => handleTopButtonClick(button.id)}
>
<button.icon className="h-5 w-5" />
<span className="sr-only">{button.label}</span>
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
<div className="flex h-full flex-col overflow-y-auto">
<div className="border-b border-[#f1f1f1] p-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<Library className="h-4 w-4" />
页面树
</div>
2025-12-27 20:23:35 +08:00
<div className="mt-2 flex items-center gap-2">
<Input
className="h-8 flex-1 rounded-md border-[#eeeeee]"
placeholder="搜索页面"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="flex rounded-md border border-[#e5e7eb] bg-white">
<button
type="button"
className={cn(
"px-3 py-1 text-xs",
viewMode === "section" ? "bg-[#2563eb] text-white" : "text-gray-600",
)}
onClick={() => setViewMode("section")}
>
分组
</button>
<button
type="button"
className={cn(
"px-3 py-1 text-xs",
viewMode === "filesystem" ? "bg-[#2563eb] text-white" : "text-gray-600",
)}
onClick={() => setViewMode("filesystem")}
>
文件
</button>
</div>
</div>
2025-11-23 10:55:04 +08:00
</div>
2025-12-27 20:23:35 +08:00
{viewMode === "section" ? (
<>
<SectionList
id="starred"
label="星标置顶"
icon={SECTION_ICONS.starred}
nodes={starredNodes}
collapsed={collapsedSections.starred}
onToggle={() => toggleSection("starred")}
/>
<SectionList
id="public"
label="公共页面"
icon={SECTION_ICONS.public}
nodes={publicNodes}
collapsed={collapsedSections.public}
onToggle={() => toggleSection("public")}
/>
<SectionList
id="shared"
label="共享页面"
icon={SECTION_ICONS.shared}
nodes={sharedNodes}
collapsed={collapsedSections.shared}
onToggle={() => toggleSection("shared")}
/>
<SectionList
id="templates"
label="模板中心"
icon={SECTION_ICONS.templates}
nodes={templateNodes}
collapsed={collapsedSections.templates}
onToggle={() => toggleSection("templates")}
/>
2025-11-23 10:55:04 +08:00
2025-12-27 20:23:35 +08:00
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<button
type="button"
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
onClick={() => toggleSection("private")}
>
<span>私有 / 我的页面</span>
<MoreHorizontal className="h-4 w-4 text-gray-400" />
</button>
{!collapsedSections.private ? (
<div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
nodes={filteredPrivateTree}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
</div>
) : (
<div className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
)}
</div>
</>
) : (
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
2025-11-23 10:55:04 +08:00
<div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
2025-12-27 20:23:35 +08:00
<FileTree
2025-11-23 10:55:04 +08:00
nodes={filteredPrivateTree}
2025-12-27 20:23:35 +08:00
assetsByDoc={assetsByDoc}
2025-11-23 10:55:04 +08:00
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
2025-12-27 20:23:35 +08:00
onOpenDocument={(id) => handleOpenDocument(id, "main")}
onOpenAsset={handleOpenAsset}
2025-11-23 10:55:04 +08:00
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
2026-01-02 07:25:50 +08:00
onAssetContextMenu={handleAssetContextMenu}
selectedAssetIds={selectedAssetIds}
onAssetClick={(asset, e) => handleSelectAsset(asset, e)}
onToggleAssetSelect={toggleAssetCheckbox}
onSelectOnlyAsset={selectOnlyAsset}
2025-11-23 10:55:04 +08:00
/>
</div>
</div>
2025-12-27 20:23:35 +08:00
</div>
)}
2025-11-23 10:55:04 +08:00
<div className="border-t border-[#f1f1f1] p-3">
<Button className="w-full justify-center gap-2" variant="outline" onClick={() => handleCreate(null)}>
<Plus className="h-4 w-4" />
新建页面
</Button>
<button
type="button"
className="mt-3 flex w-full items-center justify-between rounded-md border border-[#e8e8e8] px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setTrashOpen(true)}
>
<span className="flex items-center gap-2">
<Trash2 className="h-4 w-4 text-gray-500" />
垃圾桶
</span>
<span className="text-xs text-gray-400">{sidebarData.trashedDocuments.length} </span>
</button>
</div>
</div>
</div>
</div>
);
return (
<>
<aside className="hidden h-full md:flex" style={{ width }}>
<div className="flex h-full flex-1 flex-col border-r border-[#e9edf5] bg-white">{sidebarBody}</div>
<div className="w-1 cursor-col-resize bg-transparent" onMouseDown={handleResizeStart} />
</aside>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[92vh]">
<DrawerHeader className="text-left">
<DrawerTitle>页面目录</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-6">{sidebarBody}</div>
</DrawerContent>
</Drawer>
{contextMenu && (
<ContextMenu
contextMenu={contextMenu}
onClose={() => setContextMenu(null)}
onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")}
onMove={handleMovePrompt}
onEmbed={handleEmbedPrompt}
onCopyLink={handleCopyLink}
onCopyReference={handleCopyReference}
onCopyId={handleCopyId}
onDuplicate={handleDuplicateDocument}
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
onCreateChild={() => void handleCreate(contextMenu.node.id)}
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
onDelete={() => void handleDelete(contextMenu.node.id)}
/>
)}
2026-01-02 07:25:50 +08:00
{assetMenu && (
<AssetContextMenu
asset={assetMenu.asset}
position={{ x: assetMenu.x, y: assetMenu.y }}
onClose={() => setAssetMenu(null)}
onOpen={handleOpenAsset}
onCopyLink={handleCopyAssetLink}
onCopyPath={handleCopyAssetPath}
onRename={handleRenameAsset}
onMove={handleMoveAsset}
onDelete={(ids) =>
void handleDeleteAssets(
selectedAssetIds.size > 0 ? Array.from(selectedAssetIds) : ids,
assetMenu.asset,
)
}
onDownload={handleDownloadAsset}
/>
)}
2025-11-23 10:55:04 +08:00
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader className="text-left">
<DrawerTitle>垃圾桶</DrawerTitle>
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
</DrawerHeader>
<div className="space-y-4 px-4 pb-6">
<Input
placeholder="搜索删除的页面..."
value={trashSearch}
onChange={(event) => setTrashSearch(event.target.value)}
/>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={trashConfirm}
onChange={(event) => setTrashConfirm(event.target.checked)}
/>
操作确认
</label>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => void handleEmptyTrash()}
disabled={emptyingTrash}
>
{emptyingTrash ? "清空中..." : "清空垃圾桶"}
</Button>
</div>
<div className="rounded-lg border border-[#eaeaea]">
{filteredTrash.length === 0 ? (
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
) : (
filteredTrash.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
>
<div>
<div className="font-medium text-gray-800">{item.title || "无标题"}</div>
<div className="text-xs text-gray-400">
删除时间:{new Date(item.deleted_at).toLocaleString()}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
onClick={() => void handleRestoreFromTrash(item.id)}
>
恢复
</button>
<button
type="button"
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
onClick={() => void handlePurgeFromTrash(item.id)}
>
彻底删除
</button>
</div>
</div>
))
)}
</div>
</div>
</DrawerContent>
</Drawer>
</>
);
}
interface SectionListProps {
label: string;
icon: React.ReactNode;
nodes: DocumentNode[];
collapsed: boolean;
onToggle: () => void;
}
function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListProps) {
return (
<div className="border-b border-[#f5f5f5]">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
onClick={onToggle}
>
<span className="flex items-center gap-2">
{icon}
{label}
</span>
<span className="text-xs text-gray-400">{nodes.length}</span>
</button>
{!collapsed && (
<div className="space-y-1 px-4 pb-3">
{nodes.length === 0 ? (
<div className="text-xs text-gray-400">暂无内容</div>
) : (
nodes.map((node) => (
<Link
key={node.id}
href={`/documents/${node.id}`}
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
>
{node.title || "无标题"}
</Link>
))
)}
</div>
)}
</div>
);
}
interface ContextMenuProps {
contextMenu: ContextMenuState;
onClose: () => void;
onOpenRight: (node: DocumentNode) => void;
onMove: (node: DocumentNode) => void;
onEmbed: (node: DocumentNode) => void;
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
onCopyReference: (node: DocumentNode, mode: "inline" | "embed") => void;
onCopyId: (node: DocumentNode) => void;
onDuplicate: (node: DocumentNode) => void;
onRename: () => void;
onCreateChild: () => void;
onConvertChild: () => void;
onDelete: () => void;
}
function ContextMenu({
contextMenu,
onClose,
onOpenRight,
onMove,
onEmbed,
onCopyLink,
onCopyReference,
onCopyId,
onDuplicate,
onRename,
onCreateChild,
onConvertChild,
onDelete,
}: ContextMenuProps) {
const { node } = contextMenu;
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: contextMenu.y, left: contextMenu.x });
useLayoutEffect(() => {
const clampPosition = () => {
const element = menuRef.current;
if (!element) {
setPosition({ top: contextMenu.y, left: contextMenu.x });
return;
}
const rect = element.getBoundingClientRect();
const padding = 12;
const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding);
const maxTop = Math.max(padding, window.innerHeight - rect.height - padding);
const left = Math.min(Math.max(padding, contextMenu.x), maxLeft);
const top = Math.min(Math.max(padding, contextMenu.y), maxTop);
setPosition({ top, left });
};
clampPosition();
window.addEventListener("resize", clampPosition);
return () => window.removeEventListener("resize", clampPosition);
}, [contextMenu]);
const handleAction = (action: () => void) => {
action();
onClose();
};
const buttonClass =
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
return (
<div
ref={menuRef}
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
style={{ top: position.top, left: position.left, minWidth: 220 }}
>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onOpenRight(node))}
>
<PanelRightOpen className="h-4 w-4 text-[#2563eb]" />
<span>在右侧边栏打开</span>
<span className="ml-auto text-[11px] text-gray-400">Alt + O</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onMove(node))}
>
<ArrowRightLeft className="h-4 w-4 text-gray-500" />
<span>移动到...</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onEmbed(node))}
>
<GitMerge className="h-4 w-4 text-gray-500" />
<span>嵌入到...</span>
</button>
<div className="relative">
<button type="button" className={`${buttonClass} pr-6`} onClick={() => handleAction(() => onCopyLink(node))}>
<LinkIcon className="h-4 w-4 text-gray-500" />
<span>复制访问链接</span>
</button>
<button
type="button"
className={`${buttonClass} pr-6`}
onClick={() => handleAction(() => onCopyLink(node, true))}
>
<LinkIcon className="h-4 w-4 text-gray-500" />
<span>复制访问链接(带标题)</span>
</button>
</div>
<div className="relative group">
<button type="button" className={`${buttonClass} pr-6`}>
<Copy className="h-4 w-4 text-gray-500" />
<span>复制页面引用链接</span>
<ChevronRight className="ml-auto h-3.5 w-3.5 text-gray-400" />
</button>
<div className="invisible absolute left-full top-0 z-50 ml-1 min-w-[180px] rounded-md border border-[#e5e5e5] bg-white py-1 shadow-xl group-hover:visible">
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onCopyReference(node, "inline"))}
>
(( 行内页面引用
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onCopyReference(node, "embed"))}
>
{"{{ 嵌入页面引用"}
</button>
</div>
</div>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onCopyId(node))}
>
<Hash className="h-4 w-4 text-gray-500" />
<span>复制页面 ID</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onDuplicate(node))}
>
<Copy className="h-4 w-4 text-gray-500" />
<span>拷贝副本</span>
</button>
<div className="my-1 border-t border-[#f2f2f2]" />
<button type="button" className={buttonClass} onClick={() => handleAction(onRename)}>
<Edit3 className="h-4 w-4 text-gray-500" />
<span>重命名</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(onCreateChild)}>
<Plus className="h-4 w-4 text-gray-500" />
<span>新建子页面</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(onConvertChild)}>
<ArrowUpRight className="h-4 w-4 text-gray-500" />
<span>转为上一个子页面</span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => handleAction(onDelete)}
>
<Trash2 className="h-4 w-4" />
<span>删除到垃圾桶</span>
</button>
</div>
);
}
const cloneNodes = (nodes: DocumentNode[]): DocumentNode[] =>
nodes.map((node) => ({
...node,
children: cloneNodes(node.children),
}));
const removeNode = (
nodes: DocumentNode[],
targetId: string,
): { removed: DocumentNode | null; tree: DocumentNode[] } => {
let removed: DocumentNode | null = null;
const nextTree = nodes
.map((node) => {
if (removed) return node;
if (node.id === targetId) {
removed = node;
return null;
}
const { removed: childRemoved, tree: childTree } = removeNode(node.children, targetId);
if (childRemoved) {
removed = childRemoved;
return { ...node, children: childTree };
}
return node;
})
.filter(Boolean) as DocumentNode[];
return { removed, tree: nextTree };
};
const insertNode = (nodes: DocumentNode[], parentId: string | null, index: number, newNode: DocumentNode): DocumentNode[] => {
if (!parentId) {
const next = [...nodes];
next.splice(Math.min(index, next.length), 0, newNode);
return next;
}
return nodes.map((node) => {
if (node.id === parentId) {
const children = [...node.children];
const targetIndex = Math.min(index, children.length);
children.splice(targetIndex, 0, newNode);
return { ...node, children };
}
return { ...node, children: insertNode(node.children, parentId, index, newNode) };
});
};
const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
if (!keyword) {
return nodes;
}
const filtered: DocumentNode[] = [];
nodes.forEach((node) => {
const childMatches = filterTree(node.children, keyword);
const title = (node.title ?? "").toLowerCase();
if (title.includes(keyword) || childMatches.length > 0) {
filtered.push({ ...node, children: childMatches });
}
});
return filtered;
};
function collectNodeIds(nodes: DocumentNode[], bag: Set<string> = new Set()): Set<string> {
nodes.forEach((node) => {
bag.add(node.id);
collectNodeIds(node.children, bag);
});
return bag;
}
const buildDocumentUrl = (documentId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${documentId}`;
}
return `${window.location.origin}/documents/${documentId}`;
};
const copyText = async (text: string, successMessage: string) => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
window.alert(successMessage);
return;
} catch {
// ignore and fallback
}
}
window.prompt("复制失败,请手动复制内容", text);
};