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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button svg { width: 16px; height: 16px; flex: 0 0 16px; }
|
||||
.button:hover { background: #e9e7e3; }
|
||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import styles from "./icon-button.module.css";
|
||||
|
||||
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function IconButton({ icon: Icon, label, className = "", type = "button", ...props }, ref) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`${styles.button} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Network } from "lucide-react";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import styles from "./workspace.module.css";
|
||||
|
||||
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
|
||||
const demo = useSearchParams().get("demo") === "1";
|
||||
const router = useRouter();
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setBoard(null);
|
||||
setError("");
|
||||
if (demo) {
|
||||
try {
|
||||
const list = readDemoBoards(workspaceId);
|
||||
const item = list.find((b) => b.id === boardId);
|
||||
if (item) setBoard(item);
|
||||
else setError("图谱不存在。");
|
||||
} catch { setError("无法读取本地演示图谱。"); }
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
api<Board>(`workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`, { signal: controller.signal })
|
||||
.then((item) => {
|
||||
if (!active) return;
|
||||
if (item.kind !== "board" || item.workspace_id !== workspaceId) {
|
||||
setError("当前页面不是此工作空间的图谱。");
|
||||
return;
|
||||
}
|
||||
setBoard(item);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!active) return;
|
||||
setError(errorText(e));
|
||||
if (e.message === "invalid_session") router.replace("/login");
|
||||
});
|
||||
return () => { active = false; controller.abort(); };
|
||||
}, [workspaceId, boardId, demo, router]);
|
||||
|
||||
return (
|
||||
<main className={styles.graph} aria-label="无限图谱">
|
||||
{error ? <p className={styles.graphStatus} role="alert">{error}</p> : board ? (
|
||||
<>
|
||||
<header className={styles.graphHeader}><Network size={16} /><h1>{board.title}</h1>{demo && <span>演示</span>}</header>
|
||||
<div className={styles.graphSurface} aria-label={board.title + "的空白图谱"} />
|
||||
</>
|
||||
) : <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.shell { display: flex; min-height: 100dvh; background: #fffefb; color: #55534f; }
|
||||
.sidebar {
|
||||
position: sticky; top: 0; display: flex; flex-direction: column;
|
||||
width: 270px; max-width: calc(100vw - 40px); height: 100dvh; flex: 0 0 auto;
|
||||
padding: 12px 10px 8px; background: #f8f7f5; border-right: 1px solid #edece9;
|
||||
font-size: 13px; z-index: 30;
|
||||
}
|
||||
.topRow { display: flex; align-items: center; justify-content: space-between; margin: 0 0 12px; color: #85817b; }
|
||||
.search { display: flex; align-items: center; gap: 7px; border: 1px solid #e6e3df; border-radius: 7px; padding: 7px 9px; color: #97938d; }
|
||||
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
|
||||
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font-size: 13px; color: #55534f; }
|
||||
.search input::placeholder { color: #9a9690; }
|
||||
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 14px; }
|
||||
.home, .pageLink { display: flex; align-items: center; gap: 9px; padding: 8px; min-height: 33px; border-radius: 6px; }
|
||||
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
|
||||
.pageLink { margin: 2px 0; }
|
||||
.pageLink svg { flex-shrink: 0; color: #96928b; }
|
||||
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
|
||||
.sectionLabel { display: flex; justify-content: space-between; padding: 26px 7px 8px; color: #9b9690; font-size: 11px; }
|
||||
.hint { padding: 6px 8px; color: #9a948c; line-height: 1.7; font-size: 12px; }
|
||||
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
|
||||
.accountArea { margin-top: auto; padding-top: 10px; }
|
||||
.accountButton { display: flex; align-items: center; gap: 9px; padding: 8px; width: 100%; border-radius: 7px; text-align: left; }
|
||||
.accountButton:hover { background: #edeae5; }
|
||||
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 27px; height: 27px; border-radius: 7px; background: #e8e0d6; color: #705844; font-size: 13px; font-weight: 600; }
|
||||
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; gap: 2px; }
|
||||
.accountText strong { font-size: 12px; font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.accountText small { font-size: 10px; color: #969089; }
|
||||
.accountPopup {
|
||||
position: fixed; inset: auto auto 64px 8px; margin: 0; width: 300px; max-width: calc(100vw - 16px);
|
||||
padding: 8px; color: #55534f; background: #fff; border: 1px solid #e9e5df;
|
||||
border-radius: 10px; box-shadow: 0 8px 30px #38302612; font-size: 13px;
|
||||
}
|
||||
.accountPopup::backdrop { background: transparent; }
|
||||
.accountHeading { display: flex; align-items: center; gap: 9px; padding: 7px 6px 12px; border-bottom: 1px solid #efede9; }
|
||||
.accountHeading > div { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.accountHeading strong { font-size: 13px; font-weight: 600; }
|
||||
.accountHeading small { color: #969089; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.popupLabel { padding: 12px 7px 5px; color: #9a948c; font-size: 11px; }
|
||||
.menuItem { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
|
||||
.menuItem > span { flex: 1; }
|
||||
.menuItem:hover { background: #f4f1ec; }
|
||||
.separator { height: 1px; margin: 8px 0; background: #efede9; }
|
||||
.floating { position: fixed; left: 12px; top: 12px; z-index: 40; display: flex; gap: 6px; padding: 6px; background: #fffefa; border: 1px solid #e7e2db; border-radius: 8px; box-shadow: 0 3px 14px #3c312b0d; color: #85817b; }
|
||||
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
|
||||
.graph { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100dvh; }
|
||||
.graphHeader { display: flex; align-items: center; gap: 10px; padding: 20px 24px 20px 110px; min-height: 64px; border-bottom: 1px solid #efede8; font-size: 13px; }
|
||||
.graphHeader h1 { margin: 0; font-size: 14px; font-weight: 500; }
|
||||
.graphHeader span { color: #aaa299; }
|
||||
.graphSurface { flex: 1; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
|
||||
.graphStatus { padding: 80px 24px; color: #9a948c; text-align: center; font-size: 13px; }
|
||||
@media (max-width: 600px) { .sidebar { width: 240px; } .graphHeader { padding: 20px 14px; } }
|
||||
@@ -44,6 +44,7 @@ export const errorText = (error: unknown) => {
|
||||
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
|
||||
workspace_required: "请先创建或选择一个工作空间。",
|
||||
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
|
||||
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
|
||||
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
|
||||
invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。",
|
||||
upstream_timeout: "连接服务超时,请稍后再试。",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Board } from "./workspace";
|
||||
|
||||
export function readDemoBoards(workspace: string): Board[] {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem("endoleap_demo_boards_" + workspace) || "null");
|
||||
if (Array.isArray(stored)) return stored.map((board) => ({
|
||||
...board, workspace_id: workspace, kind: "board", version: board.version || 1,
|
||||
}));
|
||||
} catch { /* Fall back to the sample pages when browser storage is invalid. */ }
|
||||
return [
|
||||
{ id: "morning", title: "城市清晨" },
|
||||
{ id: "ideas", title: "灵感收集" },
|
||||
{ id: "journey", title: "下一站,去哪里" },
|
||||
].map((board) => ({ ...board, workspace_id: workspace, kind: "board", version: 1 }));
|
||||
}
|
||||
+18
-3
@@ -1,6 +1,19 @@
|
||||
import type { Profile } from "./api";
|
||||
|
||||
export type Workspace = { id: string; name: string };
|
||||
export type Workspace = { id: string; name: string; role?: "admin" | "editor" | "viewer" };
|
||||
export type Board = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
kind: "board";
|
||||
title: string;
|
||||
version: number;
|
||||
content?: { schema_version: number };
|
||||
};
|
||||
export const DEFAULT_BOARD_TITLE = "未命名图谱";
|
||||
|
||||
export function boardHref(workspace: string, board: string, demo = false) {
|
||||
return `/dashboard/${encodeURIComponent(workspace)}/${encodeURIComponent(board)}${demo ? "?demo=1" : ""}`;
|
||||
}
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
|
||||
export async function loadWorkspaceHome(
|
||||
@@ -32,12 +45,14 @@ export async function createBoard(
|
||||
id = workspace.id;
|
||||
onWorkspaceCreated(workspace);
|
||||
}
|
||||
return request<{ id: string }>("workspaces/" + id + "/materials", {
|
||||
const name = title.trim() || DEFAULT_BOARD_TITLE;
|
||||
const result = await request<{ id: string; workspace_id: string; kind: "board"; version: number }>("workspaces/" + id + "/materials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
kind: "board",
|
||||
title: title.trim(),
|
||||
title: name,
|
||||
content: { schema_version: 1 },
|
||||
}),
|
||||
});
|
||||
return { ...result, workspace_id: result.workspace_id || id, title: name };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user