315 lines
20 KiB
TypeScript
315 lines
20 KiB
TypeScript
/**
|
||
* 工作台共享侧栏与浮动快捷条。管理账户/工作区目录、搜索、折叠、改名和账户面板,
|
||
* 活动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<Profile | null>(null);
|
||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||
const [selectedWorkspace, setSelectedWorkspace] = useState("");
|
||
const [directoryRevision, setDirectoryRevision] = useState(0);
|
||
const [boards, setBoards] = useState<Board[]>([]);
|
||
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<HTMLButtonElement>(null);
|
||
const collapseButton = useRef<HTMLButtonElement>(null);
|
||
const hasToggled = useRef(false);
|
||
// 同步ref防重复点击创建,listVersion隔离迟到目录列表;二者都不是runtime账户authEpoch。
|
||
const createLock = useRef(false);
|
||
const listVersion = useRef(0);
|
||
const accountPopup = useRef<HTMLDivElement>(null);
|
||
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
|
||
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
|
||
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
|
||
// 目录未解析时可暂用路由 ref;这里的字符串回退不是“空间存在/有权限”的证明,后续服务请求仍需验证。
|
||
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();
|
||
// 只切换加载状态,不清空之前 profile/workspaces/error;成功读取也不会在这里自动清除旧 error。
|
||
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;
|
||
// listVersion 只保护目录结果采用,不是网络取消或账号代次;创建成功时也递增它,防较旧列表覆盖新项。
|
||
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<typeof setTimeout>;
|
||
/** 忽略其它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 {
|
||
// 捕获触发本次创建的 workspace/demo;await 后不另核对挂载/账户,宿主生命周期应避免旧操作采用到新界面。
|
||
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]);
|
||
// demo 已先更新 React 列表,随后 Storage 写失败也不回滚该列表;错误仅报告在侧栏。
|
||
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并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
|
||
// 此函数无独立 busy/ref 互斥;多次点击仍可调用。退休身份成功但上游登出失败时也不会复活旧 runtime。
|
||
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) {
|
||
// 优先使用 activeBoard.id;编辑按钮先约束路由/空间匹配,此函数本身不重新执行同一组匹配检查。
|
||
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);
|
||
// 目录更新按 ID 匹配,活动元数据更新额外比 workspace;此处不递增 listVersion,旧目录读取仍可能随后到达。
|
||
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 = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
|
||
|
||
// 尊重减少动态效果偏好;折叠侧栏同时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" ? "查看者" : "工作空间";
|
||
|
||
// 折叠只改变显示和键盘可达性,侧栏仍挂载,账户/目录状态和 effect 不因收起而销毁。
|
||
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
|
||
return (
|
||
<>
|
||
<AnimatePresence initial={false}>
|
||
{(collapsed || Boolean(params.board)) && (
|
||
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${toolbar.relaxed} ${styles.floating}`} data-sidebar-collapsed={collapsed} aria-label={params.board ? "画布快捷操作" : "侧边栏快捷操作"}
|
||
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
|
||
transition={{ duration: reduceMotion ? 0 : 0.14 }}>
|
||
{collapsed && <IconButton ref={expandButton} icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(false)} />}
|
||
<Link href="/" className={styles.logoLink} aria-label="啡跃 Endoleap 主页" title="返回主页">
|
||
<img src="/brand/png/logo-light/endoleap-32.png"
|
||
srcSet="/brand/png/logo-light/endoleap-32.png 1x, /brand/png/logo-light/endoleap-64.png 2x, /brand/png/logo-light/endoleap-128.png 4x"
|
||
width={20} height={20} alt="" />
|
||
</Link>
|
||
<span className={toolbar.divider} aria-hidden="true" />
|
||
<EditableTitle key={workspace + ":" + (params.board || "home")} title={currentTitle}
|
||
editable={Boolean(params.board && activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace && canCreate)}
|
||
onSave={renameTitle} className={styles.floatingTitle} />
|
||
<span className={toolbar.divider} aria-hidden="true" />
|
||
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("floating")}>
|
||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||
</button>
|
||
{error && <p className={styles.floatingError} role="alert">{error}</p>}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
<motion.div className={styles.sidebarSlot} initial={false}
|
||
animate={{ width: collapsed ? 0 : "var(--sidebar-width)" }} transition={transition}>
|
||
<motion.aside id="workspace-sidebar" className={styles.sidebar} aria-label="工作空间侧边栏"
|
||
aria-hidden={collapsed} inert={collapsed} initial={false}
|
||
animate={{ x: collapsed && !reduceMotion ? "-100%" : 0, opacity: collapsed ? 0 : 1 }} transition={transition}>
|
||
<div className={styles.topRow}>
|
||
<IconButton ref={collapseButton} icon={PanelLeftClose} label="收起侧边栏" aria-expanded={!collapsed} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(true)} />
|
||
{createButton}
|
||
</div>
|
||
<label className={styles.search}>
|
||
<Search size={16} strokeWidth={1.6} aria-hidden="true" />
|
||
<input aria-label="搜索页面" placeholder="搜索页面…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||
</label>
|
||
<nav className={styles.navigation} aria-label="页面导航">
|
||
<Link className={styles.home} href={`/dashboard?workspace=${encodeURIComponent(workspace)}${demo ? "&demo=1" : ""}`}>
|
||
<Home size={16} strokeWidth={1.6} /> 首页
|
||
</Link>
|
||
<div className={styles.sectionLabel}>{currentWorkspace?.name || "工作空间"}<span>{filtered.length}</span></div>
|
||
{status === "loading" && <p className={styles.hint} role="status">正在读取工作空间…</p>}
|
||
{status === "ready" && !workspace && <p className={styles.hint}>新建页面时会自动建立个人空间。</p>}
|
||
<BoardDirectory key={workspace} boards={boards} workspace={currentWorkspace?.slug || workspace} active={params.board} query={query} demo={demo} />
|
||
</nav>
|
||
{error && <p className={styles.error} role="alert">{error}</p>}
|
||
<div className={styles.accountArea}>
|
||
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("sidebar")}>
|
||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||
<span className={styles.accountText}><strong>{name}</strong><small>{currentWorkspace?.name || (demo ? "本地演示" : "个人账户")}</small></span>
|
||
<ChevronsUpDown size={14} strokeWidth={1.6} />
|
||
</button>
|
||
</div>
|
||
</motion.aside>
|
||
</motion.div>
|
||
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={accountAnchor === "floating"} data-sidebar-collapsed={collapsed} aria-label="账户和工作空间菜单">
|
||
<div className={styles.accountHeading}>
|
||
<span className={styles.workspaceAvatar} aria-hidden="true">{(currentWorkspace?.name || "啡跃").slice(0, 1)}</span>
|
||
<div><strong>{currentWorkspace?.name || "你的工作空间"}</strong><small>{roleLabel}{demo ? " · 本地演示" : ""}</small></div>
|
||
</div>
|
||
<div className={styles.menuGroup}>
|
||
<button type="button" className={styles.menuItem} disabled={!profile} onClick={() => openPanel("settings")}>
|
||
<Settings size={16} strokeWidth={1.6} /><span>账户设置</span>
|
||
</button>
|
||
</div>
|
||
<div className={styles.separator} />
|
||
<div className={styles.popupIdentity}>
|
||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||
<div><strong>{name}</strong><small>{profile?.email || profile?.username || "正在读取账户…"}</small></div>
|
||
</div>
|
||
<div className={styles.workspaceList}>
|
||
{workspaces.map((w) => (
|
||
<button type="button" key={w.id} className={styles.menuItem} aria-current={w.id === workspace ? "true" : undefined} onClick={() => {
|
||
accountPopup.current?.hidePopover();
|
||
setQuery("");
|
||
setSelectedWorkspace(w.id);
|
||
router.push(`/dashboard?workspace=${encodeURIComponent(w.id)}${demo ? "&demo=1" : ""}`);
|
||
}}>
|
||
<span className={styles.workspaceMini} aria-hidden="true">{w.name.slice(0, 1)}</span>
|
||
<span>{w.name}</span>{w.id === workspace && <Check size={15} strokeWidth={1.8} />}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button type="button" className={`${styles.menuItem} ${styles.newWorkspace}`} disabled={status !== "ready"} onClick={() => openPanel("workspace")}>
|
||
<Plus size={16} strokeWidth={1.6} /><span>新建工作空间</span>
|
||
</button>
|
||
<div className={styles.separator} />
|
||
<button type="button" className={`${styles.menuItem} ${styles.logout}`} onClick={logout}>退出登录</button>
|
||
</div>
|
||
{panel && profile && (
|
||
<AccountPanel mode={panel} profile={profile} demo={demo} onClose={() => 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" : ""}`);
|
||
}} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|