From f6315a6435fe73ed93f91628e6eb3119747788cc Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 00:28:13 +0800 Subject: [PATCH] feat: animate sidebar and refine account menu with Google avatars --- README.md | 4 +- VALIDATION.md | 9 + package-lock.json | 69 +++++++ package.json | 17 +- src/app/dashboard/dashboard.tsx | 182 ++++++++++++------ src/components/workspace/account-panel.tsx | 68 +++++++ src/components/workspace/user-avatar.tsx | 21 ++ src/components/workspace/workspace.module.css | 131 +++++++++---- src/lib/api.ts | 2 + tests/api-smoke.mjs | 32 ++- tests/workspace.test.mjs | 3 +- 11 files changed, 430 insertions(+), 108 deletions(-) create mode 100644 src/components/workspace/account-panel.tsx create mode 100644 src/components/workspace/user-avatar.tsx diff --git a/README.md b/README.md index 99b19d3..39111fe 100644 --- a/README.md +++ b/README.md @@ -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、收藏及完整布局持久化尚未接入。图谱页面目前只显示已保存的名称和空白图谱区域;演示数据不写入线上。 diff --git a/VALIDATION.md b/VALIDATION.md index 8f5339e..117a19c 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -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 头像加载验收;未修改真实账户资料。 diff --git a/package-lock.json b/package-lock.json index ea58e9e..7ee87cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4df8d4f..3e5344c 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx index 5eb2cfc..dffab3c 100644 --- a/src/app/dashboard/dashboard.tsx +++ b/src/app/dashboard/dashboard.tsx @@ -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(null); + const collapseButton = useRef(null); + const hasToggled = useRef(false); const createLock = useRef(false); const listVersion = useRef(0); const accountPopup = useRef(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 = ; - if (collapsed) return ( -
- setCollapsed(false)} /> - {createButton} - {error &&

{error}

} -
- ); + 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 ( - + + + + {panel && profile && ( + 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" : ""}`); + }} /> + )} + ); } diff --git a/src/components/workspace/account-panel.tsx b/src/components/workspace/account-panel.tsx new file mode 100644 index 0000000..92d731e --- /dev/null +++ b/src/components/workspace/account-panel.tsx @@ -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) { + 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", { 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("workspaces", { method: "POST", body: JSON.stringify({ name }) }); + onWorkspace(created); + } + onClose(); + } catch (e) { setError(errorText(e)); } + finally { setBusy(false); } + } + + return ( + { if (!open && !busy) onClose(); }}> + + + {settings ? "账户设置" : "新建工作空间"} + {settings ? "管理你的个人资料。" : "为相关的图谱和资料创建一个空间。"} + +
+ {settings ? ( + <> +
{profile.email || profile.username}
+ + + + ) : } + {error &&

{error}

} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/workspace/user-avatar.tsx b/src/components/workspace/user-avatar.tsx new file mode 100644 index 0000000..a99db18 --- /dev/null +++ b/src/components/workspace/user-avatar.tsx @@ -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(null); + const showImage = Boolean(url && url !== failedUrl); + return ( + + {showImage ? ( + {`${name}的头像`} setFailedUrl(url!)} /> + ) : } + + ); +} diff --git a/src/components/workspace/workspace.module.css b/src/components/workspace/workspace.module.css index 32dd3df..2865bc1 100644 --- a/src/components/workspace/workspace.module.css +++ b/src/components/workspace/workspace.module.css @@ -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; } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 96a30d6..bbcc349 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3,6 +3,7 @@ export type Profile = { username: string; display_name: string | null; email: string | null; + avatar_url?: string | null; }; export async function api( 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: "工作空间不存在或当前账户无权访问,请刷新空间列表。", diff --git a/tests/api-smoke.mjs b/tests/api-smoke.mjs index f22337e..03588a0 100644 --- a/tests/api-smoke.mjs +++ b/tests/api-smoke.mjs @@ -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"); diff --git a/tests/workspace.test.mjs b/tests/workspace.test.mjs index acfe432..f74053c 100644 --- a/tests/workspace.test.mjs +++ b/tests/workspace.test.mjs @@ -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"); });