feat: animate sidebar and refine account menu with Google avatars

This commit is contained in:
2026-11-06 00:28:13 +08:00
parent 71bf8b0f5e
commit f6315a6435
11 changed files with 430 additions and 108 deletions
+3 -1
View File
@@ -47,7 +47,9 @@ Google / Slack 的提供方回调仍是 Foundry 后端地址;后端的项目
- 响应式首页、锚点导航与 FAQ。
- 邮箱注册 / 登录 / 邮件验证兑换,Google 和 Slack 的 PKCE 登录入口及回调处理。
- 真实用户资料、空间列表与创建、画布列表与创建。资料和空间并行读取;首次创建画布时自动建立个人空间。
- 单侧栏第一行提供完全收起/恢复与新建页面,第二行为当前空间页面列表搜索,底部账户弹出框提供空间切换和退出登录。
- 单侧栏第一行提供完全收起/恢复与新建页面,第二行为当前空间页面列表搜索。侧栏使用 Motion for React 14.9.1 实现 240ms 开关动画,尊重系统减少动态效果偏好,收起后保留左上角快捷浮框。
- 账户菜单分为工作空间概览、账户设置、用户资料与空间切换、新建工作空间、退出登录;账户设置和新建空间均接入 Foundry。菜单文字和行距参考 Notion 收紧,工作空间分组标题保留浅灰色。
- 底部按钮、账户菜单和设置面板使用 Foundry `profile.avatar_url` 提供的 Google 头像;无头像或图片加载失败时显示姓名首字。
- 新建页面使用 Foundry Core 的 `POST /workspaces/{workspace}/materials`,提交 `kind: board`、默认名称“未命名图谱”和内容版本;由后端生成 ID。没有空间时先创建“个人空间”,成功后跳转到图谱的独立地址。
- 本地演示提供示例图谱、搜索、新建和浏览器保存,支持图谱独立地址与刷新。
- 图形编辑、连接线、分享、Agent、收藏及完整布局持久化尚未接入。图谱页面目前只显示已保存的名称和空白图谱区域;演示数据不写入线上。
+9
View File
@@ -39,3 +39,12 @@
- 在独立临时目录执行 `npm run build -- --webpack` 成功,未改写当前开发服务的构建缓存。
- 隔离生产构建的 API 冒烟测试通过,新增模拟 Foundry 的创建 201、会话转发、默认名称与 schema、按生成 ID 重新读取及图谱路由 200 检查。
- 按用户要求不访问浏览器,本次未验证实际界面交互或使用真实账户创建图谱;浏览器和线上验收由用户后续反馈。
## 侧栏动画、字体与 Google 头像(2026-11-06)
- 使用固定版本 Motion for React 14.9.1:侧栏宽度和位置以 240ms 缓动变化,浮框以 140ms 淡入淡出;系统减少动态效果偏好下立即切换。收起时侧栏设为 inert/aria-hidden,焦点转移到展开按钮。
- 侧栏主文字改为 14px、#37352f;列表内边距改为 4px 8px,行高 20px,最小高度 30px;个人空间等分组标题保持原来的 11px 浅灰色。
- 账户弹出框重新设置分区、工作空间图标、用户资料、头像、分隔线、边框和阴影,保留原生 Popover 的 Escape 与外部点击关闭;账户设置和新建工作空间接入实际 profile PATCH / workspaces POST。
- Foundry 主线已保存 OAuth picture 并通过 profile.avatar_url 返回,本次补齐前端类型和头像渲染,无需修改后端。图片失败或缺失时回退首字,图片使用 no-referrer。
- 类型检查与 6 项 workspace 测试通过;隔离临时目录的 Webpack 生产构建及 API 冒烟检查通过,新增头像 URL 透传、资料修改后保留头像的检查。
- 沿用用户“不访问浏览器,由用户试用反馈”的要求,未运行本次浏览器视觉、动画及真实 Google 头像加载验收;未修改真实账户资料。
+69
View File
@@ -13,6 +13,7 @@
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.69.0",
"motion": "14.9.1",
"next": "16.4.5",
"react": "19.3.1",
"react-dom": "19.3.1",
@@ -1876,6 +1877,33 @@
"node": ">=10.13.0"
}
},
"node_modules/framer-motion": {
"version": "14.9.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.9.1.tgz",
"integrity": "sha512-WDWNT2ixHdmpvGg02BZWI/SWwRYzPiMUf4aIkTBgrxxD8RYFdZO3fCM4pnLMo6j8mUpj0+BuJRHQBChI4v3Anw==",
"license": "MIT",
"dependencies": {
"motion-dom": "14.9.1",
"motion-utils": "14.7.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"remix": "^3.1.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"remix": {
"optional": true
}
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -2188,6 +2216,47 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/motion": {
"version": "14.9.1",
"resolved": "https://registry.npmjs.org/motion/-/motion-14.9.1.tgz",
"integrity": "sha512-u58zTNOs6vefAwvPncTJ8vkQm+P08V4+mhNgap8p/mFZjl8+gWp7SG4cMTamebVSgpX6PcDtqzWpZngqKcfd7A==",
"license": "MIT",
"dependencies": {
"framer-motion": "14.9.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"remix": "^3.1.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"remix": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "14.9.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.9.1.tgz",
"integrity": "sha512-2eCCGXH2Yv09lM8GHK3TdfChrcvHxzVY19LD9VraoHEaQl5+PuGnTNnmJaBAR+5MdxUVdsLFXygFJjR9RwRSxw==",
"license": "MIT",
"dependencies": {
"motion-utils": "14.7.0"
}
},
"node_modules/motion-utils": {
"version": "14.7.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.7.0.tgz",
"integrity": "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+9 -8
View File
@@ -9,22 +9,23 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
"@radix-ui/react-slot": "1.5.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.69.0",
"motion": "14.9.1",
"next": "16.4.5",
"react": "19.3.1",
"react-dom": "19.3.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"tailwind-merge": "3.7.0",
"@radix-ui/react-slot": "1.5.0",
"@radix-ui/react-dialog": "1.3.1",
"lucide-react": "1.69.0"
"tailwind-merge": "3.7.0"
},
"devDependencies": {
"typescript": "7.0.2",
"@tailwindcss/postcss": "4.3.4",
"@types/node": "26.6.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"tailwindcss": "4.3.4",
"@tailwindcss/postcss": "4.3.4"
"typescript": "7.0.2"
}
}
+126 -56
View File
@@ -3,15 +3,19 @@
import { useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Check, ChevronsUpDown, FilePlus2, Home, LogOut, Network, PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
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, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
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 reduceMotion = useReducedMotion();
const params = useParams<{ workspace?: string; board?: string }>();
const searchParams = useSearchParams();
const demo = searchParams.get("demo") === "1";
@@ -24,6 +28,10 @@ export default function DashboardSidebar() {
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);
@@ -94,6 +102,25 @@ export default function DashboardSidebar() {
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" });
@@ -108,63 +135,106 @@ export default function DashboardSidebar() {
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>
);
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 (
<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} />}
<>
<AnimatePresence initial={false}>
{collapsed && (
<motion.div key="sidebar-controls" className={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)} />
{createButton}
{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}>
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup}>
<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>
<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 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>
</div>
</motion.aside>
</motion.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" : ""}`);
}} />
)}
</>
);
}
@@ -0,0 +1,68 @@
"use client";
import { useState, type FormEvent } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { api, errorText, type Profile } from "@/lib/api";
import type { Workspace } from "@/lib/workspace";
import { UserAvatar } from "./user-avatar";
import styles from "./workspace.module.css";
export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorkspace }: {
mode: "settings" | "workspace";
profile: Profile;
demo: boolean;
onClose: () => void;
onProfile: (profile: Profile) => void;
onWorkspace: (workspace: Workspace) => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const settings = mode === "settings";
const name = profile.display_name || profile.username;
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
const data = new FormData(event.currentTarget);
setBusy(true);
setError("");
try {
if (settings) {
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
onProfile(updated);
} else {
const name = String(data.get("name")).trim();
const created = demo ? { id: crypto.randomUUID(), name, role: "admin" as const } : await api<Workspace>("workspaces", { method: "POST", body: JSON.stringify({ name }) });
onWorkspace(created);
}
onClose();
} catch (e) { setError(errorText(e)); }
finally { setBusy(false); }
}
return (
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
<DialogContent className={styles.settingsPanel}>
<DialogHeader>
<DialogTitle>{settings ? "账户设置" : "新建工作空间"}</DialogTitle>
<DialogDescription>{settings ? "管理你的个人资料。" : "为相关的图谱和资料创建一个空间。"}</DialogDescription>
</DialogHeader>
<form className={styles.settingsForm} onSubmit={submit}>
{settings ? (
<>
<div className={styles.settingsIdentity}><UserAvatar name={name} url={profile.avatar_url} large /><span>{profile.email || profile.username}</span></div>
<label>显示名称<input name="display_name" defaultValue={profile.display_name || ""} maxLength={80} /></label>
<label>用户名<input name="username" defaultValue={profile.username} pattern="[a-z0-9][a-z0-9_-]{2,39}" minLength={3} maxLength={40} required /></label>
</>
) : <label>空间名称<input name="name" placeholder="例如:我的创作空间" maxLength={50} required autoFocus /></label>}
{error && <p className={styles.error} role="alert">{error}</p>}
<div className={styles.settingsActions}>
<button type="button" onClick={onClose} disabled={busy}>取消</button>
<button type="submit" disabled={busy}>{busy ? "正在保存…" : settings ? "保存" : "创建空间"}</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useState } from "react";
import styles from "./workspace.module.css";
export function UserAvatar({ name, url, large = false }: {
name: string;
url?: string | null;
large?: boolean;
}) {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const showImage = Boolean(url && url !== failedUrl);
return (
<span className={`${styles.avatar} ${large ? styles.avatarLarge : ""}`}>
{showImage ? (
<img src={url!} alt={`${name}的头像`} width={large ? 36 : 24} height={large ? 36 : 24}
referrerPolicy="no-referrer" onError={() => setFailedUrl(url!)} />
) : <span aria-hidden="true">{name.slice(0, 1).toUpperCase()}</span>}
</span>
);
}
+97 -34
View File
@@ -1,53 +1,116 @@
.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;
.shell {
--sidebar-width: 270px;
display: flex;
min-height: 100dvh;
background: #fffefb;
color: #37352f;
}
.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; }
.sidebarSlot {
position: sticky;
top: 0;
height: 100dvh;
max-width: calc(100vw - 40px);
flex: 0 0 auto;
overflow: hidden;
z-index: 30;
}
.sidebar {
display: flex;
flex-direction: column;
width: var(--sidebar-width);
max-width: calc(100vw - 40px);
height: 100dvh;
padding: 10px 10px 6px;
background: #f8f7f5;
border-right: 1px solid #edece9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.15px;
color: #37352f;
}
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
.search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; 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 { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
.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; }
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
.home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; }
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
.pageLink { margin: 2px 0; }
.pageLink svg { flex-shrink: 0; color: #96928b; }
.pageLink { margin: 1px 0; }
.pageLink svg { flex-shrink: 0; color: #87837c; }
.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; }
.sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; }
.hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; 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; }
.accountArea { margin-top: auto; padding-top: 6px; }
.accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; }
.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; }
.avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; }
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountText small { display: none; }
.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;
position: fixed;
inset: auto auto 50px 8px;
margin: 0;
width: 300px;
max-width: calc(100vw - 16px);
max-height: calc(100dvh - 66px);
overflow-y: auto;
padding: 5px;
color: #37352f;
background: #fff;
border: 1px solid #e9e7e3;
border-radius: 11px;
box-shadow: 0 4px 16px #19140d14, 0 12px 40px #19140d0a;
font-family: inherit;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.15px;
transform-origin: bottom left;
}
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
.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; }
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
.workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; }
.accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountHeading small { color: #8b8780; font-size: 12px; }
.menuGroup { padding: 1px 0; }
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
.popupIdentity strong { font-size: 12px; font-weight: 500; }
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
.menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; }
.menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menuItem > svg { flex-shrink: 0; }
.menuItem:hover { background: #f1f0ed; }
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
.workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; }
.newWorkspace { color: #2383e2; }
.logout { color: #78746d; font-size: 12px; }
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
.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; }
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
.settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; }
.settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; }
.settingsForm input { padding: 8px 10px; width: 100%; border: 1px solid #e2ddd6; border-radius: 6px; background: #fff; color: #37352f; font: inherit; }
.settingsActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
.settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; }
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
.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; } }
@keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } .graphHeader { padding: 20px 14px; } }
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
+2
View File
@@ -3,6 +3,7 @@ export type Profile = {
username: string;
display_name: string | null;
email: string | null;
avatar_url?: string | null;
};
export async function api<T>(
path: string,
@@ -44,6 +45,7 @@ export const errorText = (error: unknown) => {
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
workspace_required: "请先创建或选择一个工作空间。",
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。",
+24 -8
View File
@@ -5,6 +5,10 @@ import { once } from "node:events";
import { fileURLToPath } from "node:url";
const host = "http://127.0.0.1:3005";
const graphs = new Map();
let mockProfile = {
id: "user", username: "test", display_name: "Test User", email: "test@example.com",
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
};
const upstream = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url.endsWith("/auth/logout")) {
@@ -36,13 +40,13 @@ const upstream = createServer(async (req, res) => {
} else if (req.url.endsWith("/profile")) {
res.statusCode =
req.headers.authorization === "Bearer test-only-session" ? 200 : 401;
res.end(
JSON.stringify(
res.statusCode === 200
? { username: "test" }
: { error: "invalid_session" },
),
);
if (res.statusCode === 200 && req.method === "PATCH") {
let raw = "";
for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw);
mockProfile = { ...mockProfile, username: input.username, display_name: input.display_name };
}
res.end(JSON.stringify(res.statusCode === 200 ? mockProfile : { error: "invalid_session" }));
} else {
res.statusCode = 404;
res.end("{}");
@@ -116,6 +120,18 @@ try {
200,
);
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
const profile = await (await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } })).json();
assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c");
const updated = await fetch(host + "/api/foundry/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
body: JSON.stringify({ username: "test", display_name: "Updated name" }),
});
assert.equal(updated.status, 200);
const updatedProfile = await updated.json();
assert.equal(updatedProfile.display_name, "Updated name");
assert.equal(updatedProfile.avatar_url, profile.avatar_url);
const create = await fetch(host + "/api/foundry/workspaces/space/materials", {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
@@ -179,7 +195,7 @@ try {
assert.equal(await logout.text(), "");
assert.match(logout.headers.get("set-cookie"), /Max-Age=0/);
console.log(
"PASS: App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
"PASS: avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
);
} finally {
const exited = once(app, "exit");
+2 -1
View File
@@ -12,10 +12,11 @@ test("profile and workspace reads both start before either completes", async ()
});
});
assert.deepEqual(calls, ["profile", "workspaces"]);
resolve.profile({ id: "user" });
resolve.profile({ id: "user", avatar_url: "https://lh3.googleusercontent.com/test-avatar" });
resolve.workspaces({ items: [{ id: "space", name: "个人空间" }] });
const result = await pending;
assert.equal(result.profile.id, "user");
assert.equal(result.profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar");
assert.equal(result.workspaces[0].id, "space");
});