306 lines
18 KiB
TypeScript
306 lines
18 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));
|
|
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<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 {
|
|
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 = <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" ? "查看者" : "工作空间";
|
|
|
|
// 展示分为浮动条、侧栏目录、共享账户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" : ""}`);
|
|
}} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|