feat: unify workspace sidebar and create Foundry graph pages
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user