Files
Endoleap/src/app/dashboard/dashboard.tsx
T

315 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 工作台共享侧栏与浮动快捷条。管理账户/工作区目录、搜索、折叠、改名和账户面板,
* 活动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" : ""}`);
}} />
)}
</>
);
}