271 lines
14 KiB
TypeScript
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" : ""}`);
|
|
}} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|