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
@@ -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; } }