/** * 工作台共享侧栏与浮动快捷条。管理账户/工作区目录、搜索、折叠、改名和账户面板, * 活动board元数据来自Context,画布业务状态由RuntimeHost拥有;这里不持有第二份元素文档。 * 浮动条在画布路由或侧栏折叠时显示,展开按钮仅在折叠时显示,收起按钮留在展开侧栏中。 */ "use client"; import { useEffect, useRef, useState } from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Check, ChevronsUpDown, FilePlus2, Home, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react"; import { IconButton } from "@/components/ui/icon-button"; import { api, errorText, type Profile } from "@/lib/api"; import { boardHref, createBoard, renameBoard, matchesRef, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace"; import { useActiveBoard } from "@/components/workspace/active-board"; import toolbar from "@/components/ui/toolbar.module.css"; import { EditableTitle } from "@/components/workspace/editable-title"; import { UserAvatar } from "@/components/workspace/user-avatar"; import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host"; import { AccountPanel } from "@/components/workspace/account-panel"; import { readDemoBoards } from "@/lib/demo-workspace"; import { loadBoardDirectory } from "@/lib/board-directory"; import { BoardDirectory } from "@/components/workspace/board-directory"; import styles from "@/components/workspace/workspace.module.css"; /** 路径/查询参数优先定位空间,目录解析真实ID,UI状态与账户/目录异步生命周期分别维护。 */ export default function DashboardSidebar() { const router = useRouter(); const runtimeHost = useCanvasRuntimeHost(); const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard(); const reduceMotion = useReducedMotion(); const params = useParams<{ workspace?: string; board?: string }>(); const searchParams = useSearchParams(); const demo = searchParams.get("demo") === "1"; const [profile, setProfile] = useState(null); const [workspaces, setWorkspaces] = useState([]); const [selectedWorkspace, setSelectedWorkspace] = useState(""); const [directoryRevision, setDirectoryRevision] = useState(0); const [boards, setBoards] = useState([]); const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); const [error, setError] = useState(""); const [collapsed, setCollapsed] = useState(false); const [accountAnchor, setAccountAnchor] = useState<"sidebar" | "floating">("sidebar"); const [query, setQuery] = useState(""); const [creating, setCreating] = useState(false); const [panel, setPanel] = useState<"settings" | "workspace" | null>(null); const expandButton = useRef(null); const collapseButton = useRef(null); const hasToggled = useRef(false); // 同步ref防重复点击创建,listVersion隔离迟到目录列表;二者都不是runtime账户authEpoch。 const createLock = useRef(false); const listVersion = useRef(0); const accountPopup = useRef(null); // 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。 const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace; const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef)); const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef); /** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */ useEffect(() => { if (demo) { setProfile({ id: "demo", username: "creator", display_name: "创作者", email: null }); setWorkspaces([{ id: "demo", name: "个人空间", role: "admin" }]); setSelectedWorkspace("demo"); setStatus("ready"); return; } let active = true; const controller = new AbortController(); setStatus("loading"); loadWorkspaceHome(api, controller.signal) .then(({ profile, workspaces }) => { if (!active) return; setProfile(profile); setWorkspaces(workspaces); setSelectedWorkspace(workspaces[0]?.id || ""); setStatus("ready"); }) .catch((e) => { if (!active) return; setStatus("error"); setError(errorText(e)); if (e.message === "invalid_session") router.replace("/login"); }); return () => { active = false; controller.abort(); }; }, [demo, router]); /** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */ useEffect(() => { if (!workspace || status !== "ready") return; const version = ++listVersion.current; let active = true; setBoards((list) => list.filter((board) => board.workspace_id === workspace)); if (demo) { setBoards(readDemoBoards(workspace)); return; } const controller = new AbortController(); loadBoardDirectory(api, workspace, controller.signal) .then((items) => { if (active && version === listVersion.current) setBoards(items); }) .catch((e) => { if (active) setError(errorText(e)); }); return () => { active = false; controller.abort(); }; }, [workspace, demo, status, directoryRevision]); /** 同工作区目录变化事件合并100ms后重读,清理时撤销计时和监听,不跨空间误刷新。 */ useEffect(() => { let timer: ReturnType; /** 忽略其它workspace事件;多次事件只触发一次revision递增和后续完整目录读取。 */ const refresh = (event: Event) => { if ((event as CustomEvent).detail?.workspace !== workspace) return; clearTimeout(timer); timer = setTimeout(() => setDirectoryRevision((value) => value + 1), 100); }; window.addEventListener("endoleap:board-directory-changed", refresh); return () => { clearTimeout(timer); window.removeEventListener("endoleap:board-directory-changed", refresh); }; }, [workspace]); /** 就绪且未重复创建才执行,真实模式必要时先建个人空间,demo只写本地目录。 * 成功递增listVersion防旧列表盖掉新项,再清搜索并导航;创建锁仅保护此UI操作,不替代账户身份护栏。 */ async function newPage() { if (createLock.current || status !== "ready") return; createLock.current = true; setCreating(true); setError(""); try { const board = demo ? { id: crypto.randomUUID(), workspace_id: workspace, title: DEFAULT_BOARD_TITLE, kind: "board" as const, version: 1 } : await createBoard(api, DEFAULT_BOARD_TITLE, workspace, (created) => { setWorkspaces((list) => [...list, created]); setSelectedWorkspace(created.id); }); listVersion.current += 1; setBoards((list) => [board, ...list]); if (demo) localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify([board, ...readDemoBoards(workspace)])); setQuery(""); router.push(boardHref(currentWorkspace?.slug || board.workspace_id, board.slug || board.id, demo)); } catch (e) { setError(errorText(e)); } finally { createLock.current = false; setCreating(false); } } /** 用户实际切换折叠后,下一帧聚焦新出现的展开/收起按钮;初始渲染不抢焦点。 */ useEffect(() => { if (!hasToggled.current) return; const frame = requestAnimationFrame(() => { (collapsed ? expandButton : collapseButton).current?.focus(); }); return () => cancelAnimationFrame(frame); }, [collapsed]); /** 先关闭账户popover再切布局,避免旧锚点残留弹层;焦点转移交给effect。 */ function toggleSidebar(value: boolean) { accountPopup.current?.hidePopover(); hasToggled.current = true; setCollapsed(value); } /** 关闭popover后打开设置/新空间对话框,避免两层交互同时争焦点。 */ function openPanel(mode: "settings" | "workspace") { accountPopup.current?.hidePopover(); setPanel(mode); } /** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */ async function logout() { try { if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); } router.push("/login"); } catch (e) { setError(errorText(e)); } } // 已解析活动board标题优先,退回目录标题或加载文案;不从另一工作区的活动记录借名。 const currentTitle = activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace ? activeBoard.title : boards.find((board) => matchesRef(board, params.board))?.title || (params.board ? "正在读取图谱…" : "工作空间"); const name = profile?.display_name || profile?.username || "账户"; // 计数用标题直接匹配;树中保留的搜索祖先由BoardDirectory独立派生,因此数目不等于所有可见行数。 const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())); // UI能力只作提示/禁用,真实创建权限仍由服务端判定,未知空间不能当成已有编辑权限。 const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer")); /** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */ async function renameTitle(title: string) { const id = activeBoard?.id || params.board; if (!id || !workspace) return; try { if (demo) { const list = readDemoBoards(workspace); localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify(list.map((board) => board.id === id ? { ...board, title } : board))); } else await renameBoard(api, workspace, id, title); setBoards((list) => list.map((board) => board.id === id ? { ...board, title } : board)); setActiveBoard((board) => board?.id === id && board.workspace_id === workspace ? { ...board, title } : board); } catch (error) { throw new Error(errorText(error)); } } // 创建入口复用同一busy/权限状态,降低重复点击,但不在render中发请求。 const createButton = ; // 尊重减少动态效果偏好;折叠侧栏同时inert/aria-hidden,避免不可见内容仍接受键盘交互。 const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const }; const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间"; // 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。 return ( <> {(collapsed || Boolean(params.board)) && ( {collapsed && toggleSidebar(false)} />} )}
toggleSidebar(true)} /> {createButton}
{error &&

{error}

}
{currentWorkspace?.name || "你的工作空间"}{roleLabel}{demo ? " · 本地演示" : ""}
{name}{profile?.email || profile?.username || "正在读取账户…"}
{workspaces.map((w) => ( ))}
{panel && profile && ( setPanel(null)} onProfile={setProfile} onWorkspace={(created) => { if (demo) localStorage.setItem("endoleap_demo_boards_" + created.id, "[]"); setWorkspaces((list) => [...list, created]); setSelectedWorkspace(created.id); setQuery(""); router.push(`/dashboard?workspace=${encodeURIComponent(created.id)}${demo ? "&demo=1" : ""}`); }} /> )} ); }