feat: unify workspace sidebar and create Foundry graph pages

This commit is contained in:
2026-11-05 23:57:45 +08:00
parent 6ca4b00d2c
commit 71bf8b0f5e
15 changed files with 417 additions and 238 deletions
@@ -0,0 +1,11 @@
import { Suspense } from "react";
import GraphPage from "@/components/workspace/graph-page";
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
const { workspace, board } = await params;
return (
<Suspense fallback={<p role="status">正在打开图谱…</p>}>
<GraphPage workspaceId={workspace} boardId={board} />
</Suspense>
);
}
+128 -215
View File
@@ -1,257 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
Plus,
Folder,
FileText,
LayoutGrid,
LogOut,
ChevronDown,
Check,
Sun,
Moon,
} from "lucide-react";
import { Brand } from "@/components/brand";
import { Icon } from "@/components/icon";
import { Check, ChevronsUpDown, FilePlus2, Home, LogOut, Network, PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
import { IconButton } from "@/components/ui/icon-button";
import { api, errorText, type Profile } from "@/lib/api";
import { loadWorkspaceHome, type Workspace } from "@/lib/workspace";
import { boardHref, createBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
import { readDemoBoards } from "@/lib/demo-workspace";
import styles from "@/components/workspace/workspace.module.css";
type Board = { id: string; title: string };
const demoBoards: Board[] = [
{ id: "morning", title: "城市清晨" },
{ id: "ideas", title: "灵感收集" },
{ id: "journey", title: "下一站,去哪里" },
];
export default function Dashboard() {
export default function DashboardSidebar() {
const router = useRouter();
const demo = useSearchParams().get("demo") === "1";
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 [workspace, setWorkspace] = useState("");
const [selectedWorkspace, setSelectedWorkspace] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [view, setView] = useState("全部文件");
const [workspaceMenu, setWorkspaceMenu] = useState(false);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
const [dark, setDark] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [query, setQuery] = useState("");
const [creating, setCreating] = useState(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: "个人空间" }]);
setWorkspace("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: list }) => {
.then(({ profile, workspaces }) => {
if (!active) return;
setProfile(profile);
setWorkspaces(list);
setWorkspace(list[0]?.id || "");
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");
else setError(errorText(e));
});
return () => {
active = false;
controller.abort();
};
return () => { active = false; controller.abort(); };
}, [demo, router]);
useEffect(() => {
if (!workspace) return;
setError("");
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
let active = true;
setBoards([]);
if (demo) {
try {
setBoards(
JSON.parse(
localStorage.getItem("endoleap_demo_boards_" + workspace) || "null",
) || demoBoards,
);
} catch {
setBoards(demoBoards);
}
setBoards(readDemoBoards(workspace));
return;
}
let active = true;
const controller = new AbortController();
api<{ items: Board[] }>(
"workspaces/" + workspace + "/materials?kind=board&sort=modified&limit=100",
{ signal: controller.signal },
)
.then((result) => {
if (active) setBoards(result.items);
})
.catch((e) => {
if (active) setError(errorText(e));
});
return () => {
active = false;
controller.abort();
};
}, [workspace, demo]);
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); }
}
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
router.push("/login");
} catch (e) {
setError(errorText(e));
}
} catch (e) { setError(errorText(e)); }
}
const names = workspaces.find((w) => w.id === workspace)?.name || "工作空间";
return (
<div className={"dashboard sidebar-only " + (dark ? "dark-workspace" : "")}>
<aside className="icon-rail">
<Link href="/" aria-label="首页">
<img
src="/assets/dashboardcontext-imgFrame.svg"
width="25"
height="25"
alt="啡跃"
/>
</Link>
<div className="rail-nav">
{[
["LayoutDashboard", "工作台"],
["Layers", "图层"],
["Search", "搜索"],
["ImagePlus", "素材"],
["Bot", "Agent"],
].map(([icon, label]) => (
<button
title={label}
aria-label={label}
className={label === "工作台" ? "active" : ""}
key={label}
disabled={label !== "工作台"}
onClick={() => setView("全部文件")}
>
<Icon name={icon} size={17} />
</button>
))}
</div>
<div className="rail-bottom">
<button aria-label="切换亮暗主题" onClick={() => setDark(!dark)}>
{dark ? <Sun size={17} /> : <Moon size={17} />}
</button>
<button aria-label="退出登录" title="退出登录" onClick={logout}>
<LogOut size={17} />
</button>
</div>
</aside>
<aside className="workspace-sidebar">
<div className="workspace-switch">
<button
onClick={() => setWorkspaceMenu(!workspaceMenu)}
aria-expanded={workspaceMenu}
>
<span className="workspace-avatar">↑</span>
<span>{names}</span>
<ChevronDown size={13} />
</button>
{workspaceMenu && (
<div className="workspace-menu">
{workspaces.map((w) => (
<button
key={w.id}
onClick={() => {
if (workspace !== w.id) setBoards([]);
setWorkspace(w.id);
setWorkspaceMenu(false);
setView("全部文件");
}}
>
{w.name}
{workspace === w.id && <Check size={14} />}
</button>
))}
<button disabled title="新建工作空间暂未开放">
<Plus size={14} /> 新建工作空间
</button>
</div>
)}
</div>
<div className="profile-row">
<span className="user-avatar">
{(profile?.display_name || profile?.username || "U")
.slice(0, 1)
.toUpperCase()}
</span>
<span>
{profile?.display_name || profile?.username || "创作者"}
</span>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="sidebar-label">工作空间</div>
{[
["全部文件", LayoutGrid],
["最近打开", FileText],
["我的收藏", Folder],
].map(([label, Comp]) => {
const C = Comp as typeof Folder;
return (
<button
key={label as string}
className={
"side-item " + (view === label ? "selected" : "")
}
onClick={() => setView(label as string)}
>
<C size={16} />
{label as string}
</button>
);
})}
<div className="sidebar-label sidebar-label-flex">
画布{" "}
<button
aria-label="新建画布"
disabled
title="新建画布暂未开放"
>
<Plus size={15} />
</button>
</div>
{boards.map((b) => (
<button
key={b.id}
className={"side-item " + (view === "board:" + b.id ? "selected" : "")}
onClick={() => setView("board:" + b.id)}
>
<Icon name="Scan1" size={15} />
{b.title}
</button>
))}
<button
className="side-item muted"
disabled
title="新建画布暂未开放"
>
<Plus size={15} />
新建画布
</button>
<div className="sidebar-bottom">
<p>给想法留白,给创作空间。</p>
<div className="sidebar-bottom-line">
<Brand />
{demo && <span className="demo-badge">演示</span>}
</div>
</div>
</aside>
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"));
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
if (collapsed) return (
<div className={styles.floating} aria-label="侧边栏快捷操作">
<IconButton icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} onClick={() => setCollapsed(false)} />
{createButton}
{error && <p className={styles.floatingError} role="alert">{error}</p>}
</div>
);
return (
<aside className={styles.sidebar} aria-label="工作空间侧边栏">
<div className={styles.topRow}>
<IconButton icon={PanelLeftClose} label="收起侧边栏" aria-expanded={true} onClick={() => { accountPopup.current?.hidePopover(); setCollapsed(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={demo ? "/dashboard?demo=1" : "/dashboard"}>
<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}>
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup}>
<div className={styles.accountHeading}><span className={styles.avatar}>{name.slice(0, 1).toUpperCase()}</span><div><strong>{name}</strong><small>{profile?.email || (demo ? "本地演示账户" : profile?.username)}</small></div></div>
<div className={styles.popupLabel}>工作空间</div>
{workspaces.map((w) => (
<button type="button" key={w.id} className={styles.menuItem} onClick={() => {
accountPopup.current?.hidePopover();
setQuery("");
setSelectedWorkspace(w.id);
router.push(`/dashboard?workspace=${encodeURIComponent(w.id)}${demo ? "&demo=1" : ""}`);
}}>
<span>{w.name}</span>{w.id === workspace && <Check size={14} />}
</button>
))}
<div className={styles.separator} />
<button type="button" className={styles.menuItem} onClick={logout}><LogOut size={16} />退出登录</button>
</div>
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<span className={styles.avatar}>{name.slice(0, 1).toUpperCase()}</span>
<span className={styles.accountText}><strong>{name}</strong><small>{currentWorkspace?.name || (demo ? "本地演示" : "个人账户")}</small></span>
<ChevronsUpDown size={14} strokeWidth={1.6} />
</button>
</div>
</aside>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Suspense, type ReactNode } from "react";
import DashboardSidebar from "./dashboard";
import styles from "@/components/workspace/workspace.module.css";
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<div className={styles.shell}>
<Suspense fallback={<aside className={styles.sidebar} aria-label="正在加载侧边栏" aria-busy="true" />}>
<DashboardSidebar />
</Suspense>
{children}
</div>
);
}
+1 -13
View File
@@ -1,15 +1,3 @@
import { Suspense } from "react";
import Dashboard from "./dashboard";
export default function DashboardPage() {
return (
<Suspense fallback={
<div className="dashboard sidebar-only" aria-label="正在加载侧边栏" aria-busy="true">
<aside className="icon-rail" />
<aside className="workspace-sidebar" />
</div>
}>
<Dashboard />
</Suspense>
);
return null;
}