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

271 lines
14 KiB
TypeScript

"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, Network, 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, 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 { AccountPanel } from "@/components/workspace/account-panel";
import { readDemoBoards } from "@/lib/demo-workspace";
import styles from "@/components/workspace/workspace.module.css";
export default function DashboardSidebar() {
const router = useRouter();
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 [boards, setBoards] = useState<Board[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
const [collapsed, setCollapsed] = useState(false);
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);
const createLock = useRef(false);
const listVersion = useRef(0);
const accountPopup = useRef<HTMLDivElement>(null);
const workspace = params.workspace || searchParams.get("workspace") || selectedWorkspace;
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]);
useEffect(() => {
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
let active = true;
setBoards([]);
if (demo) {
setBoards(readDemoBoards(workspace));
return;
}
const controller = new AbortController();
api<{ items: Board[] }>(`workspaces/${encodeURIComponent(workspace)}/materials?kind=board&sort=modified&limit=100`, { signal: 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]);
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(board.workspace_id, 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]);
function toggleSidebar(value: boolean) {
accountPopup.current?.hidePopover();
hasToggled.current = true;
setCollapsed(value);
}
function openPanel(mode: "settings" | "workspace") {
accountPopup.current?.hidePopover();
setPanel(mode);
}
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
router.push("/login");
} catch (e) { setError(errorText(e)); }
}
const currentTitle = activeBoard && activeBoard.id === params.board && activeBoard.workspace_id === workspace
? activeBoard.title
: boards.find((board) => board.id === params.board)?.title || (params.board ? "正在读取图谱…" : "工作空间");
const name = profile?.display_name || profile?.username || "账户";
const currentWorkspace = workspaces.find((w) => w.id === workspace);
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
async function renameTitle(title: string) {
const 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)); }
}
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
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" ? "查看者" : "工作空间";
return (
<>
<AnimatePresence initial={false}>
{collapsed && (
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${styles.floating}`} aria-label="侧边栏快捷操作"
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 }}>
<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>
<EditableTitle key={workspace + ":" + (params.board || "home")} title={currentTitle}
editable={Boolean(params.board && activeBoard?.id === params.board && activeBoard.workspace_id === workspace && canCreate)}
onSave={renameTitle} className={styles.floatingTitle} />
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<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>}
{filtered.map((board) => (
<Link key={board.id} className={styles.pageLink} aria-current={params.board === board.id ? "page" : undefined} href={boardHref(workspace, board.id, demo)}>
<Network size={16} strokeWidth={1.6} aria-hidden="true" />
<span>{board.title}</span>
</Link>
))}
{query && !filtered.length && <p className={styles.hint}>没有找到相关页面</p>}
</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="账户和工作空间">
<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={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" : ""}`);
}} />
)}
</>
);
}