Build a dual-sidebar workspace dashboard
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Successful in 7s
Unified application CI and deployment / verify-and-release (push) Has been skipped

This commit is contained in:
2026-11-04 15:07:20 +08:00
parent b1df6077cd
commit 484cc0ba84
2 changed files with 230 additions and 6 deletions
+167 -5
View File
@@ -1,7 +1,169 @@
import { ThemeToggle } from "../components/ThemeToggle";
import { Brand } from "../components/Brand";
import Head from 'next/head';
import {useEffect,useState} from 'react';
import {ArrowUpRight, Layers, Workflow, BookOpen, Link2} from 'lucide-react';
import {useEffect, useRef, useState} from 'react';
import {ArrowLeft, ArrowUpRight, BookOpen, Bot, ChevronDown, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
import {Brand} from '../components/Brand';
import {ThemeToggle} from '../components/ThemeToggle';
import s from '@/styles/workspace.module.css';
export default function Workspace(){const [email,setEmail]=useState(''),[state,setState]=useState('loading');useEffect(()=>{fetch('/api/account/session').then(async r=>{if(!r.ok)throw Error();const d=await r.json();if(!d.user){window.location.replace('/account?next=/workspace');return}setEmail(d.user.email);setState('ready')}).catch(()=>setState('error'))},[]);return <main className={s.app}><Head><title>工作台 · 啡跃 Studio</title></Head><aside><a className={s.brand} href="/"><Brand/></a><span>你的工作空间</span><a aria-current="page" href="/workspace">◈ 创作概览</a><a href="/graphic"><Layers size={16}/>图形设计</a><a href="/infinite_canvas"><Workflow size={16}/>无限画布</a><a href="/documents"><BookOpen size={16}/>知识与教程</a><a href="/studio/api"><Link2 size={16}/>Agent 接入文档</a><a className={s.account} href="/account">{email||'账户设置'} ↗</a></aside><section>{state==='loading'?<p role="status">正在打开你的工作台…</p>:state==='error'?<p role="alert">暂时无法读取账户。<a href="/account?next=/workspace">重新登录</a></p>:<><header><span>WORKSPACE / OVERVIEW</span><ThemeToggle/><a href="/account">账户设置 ↗</a></header><p className={s.kicker}>MAKE SOMETHING THAT MATTERS.</p><h1>今天,让哪个想法成形?</h1><p className={s.sub}>选一个空间,开始你的下一次创作。</p><div className={s.grid}><a href="/graphic"><Layers/><small>GRAPHIC</small><h2>图形设计</h2><p>在文字、图形与图像之间,找到你的表达。</p><div className={s.poster}>Ideas,<br/><i>in shape.</i><b>✳</b></div><strong>打开编辑器 <ArrowUpRight size={18}/></strong></a><a href="/infinite_canvas"><Workflow/><small>INFINITE CANVAS</small><h2>无限画布</h2><p>收集灵感、连接参考,让思路自由生长。</p><div className={s.canvas}><span>灵感</span>⟶<span>参考</span>⟶<span>作品</span></div><strong>打开画布 <ArrowUpRight size={18}/></strong></a></div><div className={s.agent}><Link2/><div><h3>带上你的 coding agent</h3><p>从接入文档开始,使用已有的创作 API。连接由你的 Agent 发起。</p></div><a href="/studio/api">查看文档 ↗</a></div><p className={s.hint}>编辑器草稿保存在当前浏览器。更换设备前,请先导出项目备份。</p></>}</section></main>}
const tools = [
{label: '无限画布', detail: '收集灵感,连接文字与参考素材', href: '/infinite_canvas', icon: Workflow},
{label: '图形设计', detail: '编辑文字、图像与版式', href: '/graphic', icon: Palette},
{label: '知识与教程', detail: '查找指南和创作方法', href: '/documents', icon: BookOpen},
{label: 'Agent 接入', detail: '了解如何连接创作工具与 API', href: '/studio/api', icon: Bot},
];
const purposes = [
{label: '工作与团队', detail: '整理项目和工作资料。', icon: LayoutGrid},
{label: '个人创作', detail: '收集灵感,让想法成形。', icon: House},
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
];
type Space = {name: string; purpose: string};
const personal = {name: '个人空间', purpose: ''};
export default function Workspace() {
const [email, setEmail] = useState('');
const [state, setState] = useState('loading');
const [sidebar, setSidebar] = useState(true);
const [spaces, setSpaces] = useState<Space[]>([]);
const [active, setActive] = useState(personal);
const [query, setQuery] = useState('');
const [step, setStep] = useState<'purpose' | 'name'>('purpose');
const [purpose, setPurpose] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const dialog = useRef<HTMLDialogElement>(null);
const switcher = useRef<HTMLDetailsElement>(null);
const search = useRef<HTMLInputElement>(null);
useEffect(() => {
setSidebar(window.matchMedia('(min-width: 761px)').matches);
const request = new AbortController();
fetch('/api/account/session', {signal: request.signal}).then(async response => {
if (!response.ok) throw Error();
const data = await response.json();
if (!data.user) {
window.location.replace('/account?next=/workspace');
return;
}
setEmail(data.user.email);
try {
const saved = JSON.parse(localStorage.getItem('studio.workspace:' + data.user.email) || 'null');
if (Array.isArray(saved?.spaces)) {
setSpaces(saved.spaces);
setActive(saved.spaces.find((space: Space) => space.name === saved.active) || personal);
}
} catch {}
setState('ready');
}).catch(() => {
if (!request.signal.aborted) setState('error');
});
return () => request.abort();
}, []);
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(''), 4500);
return () => window.clearTimeout(timeout);
}, [notice]);
function selectSpace(space: Space, next = spaces) {
localStorage.setItem('studio.workspace:' + email, JSON.stringify({spaces: next, active: space.name}));
setSpaces(next);
setActive(space);
switcher.current?.removeAttribute('open');
}
function openDialog() {
setStep('purpose');
setPurpose('');
setName('');
setError('');
dialog.current?.showModal();
}
function createSpace(event: React.FormEvent) {
event.preventDefault();
const clean = name.trim();
if (!clean) return setError('请填写 Workspace 名称。');
if ([personal, ...spaces].some(space => space.name === clean)) return setError('已有同名空间。');
const space = {name: clean, purpose};
try {
selectSpace(space, [...spaces, space]);
dialog.current?.close();
setNotice('已创建并切换到「' + clean + '」');
} catch {
setError('浏览器无法保存此空间,请检查存储权限后重试。');
}
}
const visible = tools.filter(tool => (tool.label + tool.detail).includes(query.trim()));
return <div className={s.app}>
<Head><title>工作台 · 啡跃</title></Head>
<nav className={s.rail} aria-label="全局导航">
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand compact/></a>
<button type="button" aria-label="切换工作台导航" title="切换工作台导航" aria-current="page" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><LayoutGrid size={18}/></button>
<a href="/infinite_canvas" aria-label="无限画布" title="无限画布"><Workflow size={18}/></a>
<button type="button" aria-label="搜索工作台" title="搜索工作台" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
<a href="/graphic" aria-label="图形设计" title="图形设计"><Palette size={18}/></a>
<a href="/studio/api" aria-label="Agent 接入" title="Agent 接入"><Bot size={18}/></a>
<a className={s.settings} href="/account" aria-label="账户设置" title="账户设置"><Settings size={18}/></a>
</nav>
{sidebar && <aside className={s.panel} id="workspace-navigation">
<div className={s.identity}>
<details ref={switcher} className={s.switcher}>
<summary><span className={s.avatar}>{Array.from(active.name)[0]}</span><strong>{active.name}</strong><ChevronDown size={14}/></summary>
{state === 'ready' && <div className={s.menu}>
{[personal, ...spaces].map(space => <button type="button" key={space.name} aria-pressed={space.name === active.name} onClick={() => {
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
}}>{space.name}</button>)}
<button type="button" onClick={openDialog}><Plus size={16}/>添加 Workspace</button>
</div>}
</details>
<a className={s.avatar} href="/account" aria-label="账户设置" title={email || '账户设置'}>{email ? email.charAt(0).toUpperCase() : <Settings size={15}/>}</a>
</div>
<div className={s.panelTitle}><strong>工作台</strong>{state === 'ready' && <button type="button" onClick={openDialog} aria-label="添加 Workspace" title="添加 Workspace"><Plus size={18}/></button>}</div>
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索工具…" aria-label="搜索工具"/></label>
<nav className={s.links} aria-label="空间工具">
<a href="/workspace" aria-current="page"><LayoutGrid size={16}/>创作概览</a>
<p>创作工具</p>
{visible.map(({label, href, icon: Icon}) => <a key={href} href={href}><Icon size={16}/>{label}</a>)}
</nav>
<small className={s.local}>空间设置保存在此浏览器</small>
</aside>}
<main className={s.main}>
<header className={s.top}>
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / 创作概览</span></div>
<ThemeToggle/>
</header>
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
<div className={s.content}>
<section className={s.intro}><Sparkles size={20}/><p>{active.purpose || '个人空间'}</p><h1>{active.name}</h1><p>从画布开始整理想法,或打开图形编辑器继续创作。</p><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></section>
<section aria-labelledby="workspace-tools">
<h2 id="workspace-tools">创作工具</h2>
<div className={s.tools}>{visible.map(({label, detail, href, icon: Icon}) => <a href={href} key={href}><Icon size={20}/><span><strong>{label}</strong><small>{detail}</small></span><ArrowUpRight size={16}/></a>)}</div>
{!visible.length && <p role="status">没有找到匹配的工具。</p>}
</section>
</div>}
</main>
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
<div className={s.dialogTop}>
{step === 'name' && <button type="button" aria-label="返回用途选择" onClick={() => setStep('purpose')}><ArrowLeft size={20}/></button>}
<h2 id="workspace-dialog-title">{step === 'purpose' ? '这个空间用来做什么?' : '给你的 Workspace 命名'}</h2>
<button type="button" aria-label="关闭" onClick={() => dialog.current?.close()}><X size={20}/></button>
</div>
{step === 'purpose' ? <>
<p>选择一个起点。空间设置仅保存在此浏览器。</p>
<div className={s.options}>{purposes.map(({label, detail, icon: Icon}) => <button key={label} type="button" aria-pressed={purpose === label} onClick={() => setPurpose(label)}><Icon size={23}/><span><strong>{label}</strong><small>{detail}</small></span></button>)}</div>
<button className={s.primary} type="button" disabled={!purpose} onClick={() => setStep('name')}>继续 <ArrowUpRight size={16}/></button>
</> : <form onSubmit={createSpace}>
<p>{purpose} · 一个空间,装下你的想法与作品。</p>
<div className={s.iconPreview}><span className={s.avatar}>{Array.from(name.trim())[0] || '空'}</span>空间图标</div>
<label htmlFor="workspace-name">Workspace 名称</label>
<input id="workspace-name" autoFocus value={name} maxLength={40} onChange={e => {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/>
{error && <p id="workspace-error" role="alert" className={s.error}>{error}</p>}
<button className={s.primary} type="submit">创建 Workspace <ArrowUpRight size={16}/></button>
</form>}
</dialog>
{notice && <div role="status" className={s.notice}>{notice}</div>}
</div>;
}
@@ -1 +1,63 @@
.app{display:flex;min-height:100dvh;background:var(--background);color:var(--foreground-strong);font-family:Arial,'PingFang SC',sans-serif}.app *{box-sizing:border-box}.app a{color:inherit;text-decoration:none}.app>aside{width:230px;flex-shrink:0;border-right:1px solid var(--border);background:var(--selection);padding:30px 20px;display:flex;flex-direction:column;gap:9px}.brand{font-size:22px;font-weight:600;margin-bottom:45px}.brand small{font-size:9px;letter-spacing:1px;color:var(--muted-foreground)}.app aside>span{font-size:10px;color:var(--muted-foreground);margin-bottom:10px;padding-left:10px}.app aside>a:not(.brand){display:flex;align-items:center;gap:12px;font-size:12px;padding:12px;border-radius:7px}.app aside>a[aria-current]{background:var(--selection)}.app aside>.account{margin-top:auto;overflow-wrap:anywhere}.app>section{padding:35px 55px;max-width:1250px;width:100%;margin:auto;min-height:100dvh}.app header{display:flex;justify-content:space-between;color:var(--muted-foreground);font-size:10px;letter-spacing:1px}.kicker{font-size:10px;letter-spacing:2px;color:var(--muted-foreground);margin-top:65px}.app h1{font-size:34px;font-weight:500;letter-spacing:-1px;margin:18px 0}.sub{font-size:13px;color:var(--muted-foreground)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:35px}.grid>a{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:26px;transition:transform .2s}.grid>a:hover{transform:translateY(-3px)}.grid small{font-size:9px;letter-spacing:1.5px;margin-left:12px;color:var(--muted-foreground)}.grid>a>svg{vertical-align:middle;width:20px;color:var(--muted-foreground)}.grid h2{font-size:23px;font-weight:500;margin:24px 0 12px}.grid p{font-size:12px;color:var(--muted-foreground);line-height:1.8}.poster,.canvas{height:190px;margin:25px 0;background:#ecf0e3;position:relative;overflow:hidden;border-radius:5px}.poster{padding:25px;font-size:40px;line-height:1;font-weight:600;letter-spacing:-2px}.poster i{font-family:Georgia,serif;font-weight:400}.poster b{position:absolute;right:0;top:12px;font-size:155px;color:#a7b88b}.canvas{display:flex;align-items:center;justify-content:center;gap:12px;background:radial-gradient(var(--selection) 1px,transparent 1px) 0 0/18px 18px,var(--selection);color:var(--muted-foreground)}.canvas span{background:var(--surface);border:1px solid var(--selection-border);padding:20px 13px;font-size:12px;border-radius:6px;color:var(--foreground)}.grid strong{display:flex;justify-content:space-between;font-size:12px;font-weight:400}.agent{display:flex;align-items:center;gap:18px;border:1px solid var(--selection-border);border-radius:10px;padding:23px;margin-top:28px}.agent>svg{color:var(--muted-foreground)}.agent h3{font-size:14px;margin:0 0 8px}.agent p{font-size:11px;color:var(--muted-foreground);margin:0;line-height:1.8}.agent a{margin-left:auto;font-size:12px;white-space:nowrap}.hint{font-size:10px;color:var(--muted-foreground);margin-top:25px}.app a:focus-visible{outline:2px solid var(--ring);outline-offset:4px}@media(max-width:850px){.app>aside{width:180px}.app>section{padding:30px 25px}.grid{gap:15px}.grid>a{padding:20px}.app h1{font-size:28px}.canvas{gap:5px}.canvas span{padding:15px 8px}}@media(max-width:600px){.app{display:block}.app>aside{width:100%;flex-direction:row;flex-wrap:wrap;padding:18px;gap:5px;border-right:0;border-bottom:1px solid var(--border)}.brand{margin:0;width:100%}.app aside>span,.app aside>.account{display:none}.app aside>a:not(.brand){padding:8px;font-size:10px}.grid{grid-template-columns:1fr}.kicker{margin-top:35px}.agent{flex-wrap:wrap}.agent a{margin-left:0}}
.app { display: flex; min-height: 100dvh; background: var(--background); color: var(--foreground-strong); font-size: 14px; }
.rail, .panel { position: sticky; top: 0; height: 100dvh; border-right: 1px solid var(--border); flex-shrink: 0; }
.rail { width: 50px; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 10px 5px; background: var(--surface-muted); }
.rail > a, .rail > button, .panelTitle button, .top button, .dialogTop button { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border-radius: 7px; color: var(--muted-foreground); }
.rail > :hover, .links a:hover, .top button:hover, .panelTitle button:hover, .dialogTop button:hover, .menu button:hover { background: var(--row-hover); }
.rail [aria-current], .links a[aria-current], .menu button[aria-pressed=true] { background: var(--selection); color: var(--foreground-strong); }
.logo { margin-bottom: 14px; }
.logo :global(.endoleap-wordmark) { display: none; }
.logo :global(.endoleap-mark) { width: 26px; height: 26px; }
.settings { margin-top: auto; }
.panel { width: 248px; padding: 0 12px 14px; display: flex; flex-direction: column; background: var(--surface); }
.identity, .panelTitle { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.identity { min-height: 58px; border-bottom: 1px solid var(--border); }
.panelTitle { padding: 8px 4px; min-height: 50px; }
.avatar { display: grid; place-items: center; flex: 0 0 auto; width: 28px; height: 28px; border-radius: 7px; background: var(--selection); color: var(--foreground-strong); }
.switcher { min-width: 0; position: relative; flex: 1; }
.switcher summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 7px; min-height: 36px; }
.switcher summary::-webkit-details-marker { display: none; }
.switcher strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.switcher summary svg { margin-left: auto; flex-shrink: 0; }
.menu { position: absolute; top: 42px; left: 0; width: 222px; z-index: 2; padding: 6px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); box-shadow: 0 8px 20px var(--shadow); max-height: 55dvh; overflow: auto; }
.menu button, .links a { display: flex; align-items: center; text-align: left; gap: 9px; width: 100%; min-height: 36px; padding: 6px 9px; border-radius: 6px; overflow-wrap: anywhere; }
.search { display: flex; align-items: center; gap: 8px; background: var(--surface-muted); color: var(--muted-foreground); border-radius: 7px; padding: 8px; }
.search input { min-width: 0; width: 100%; background: transparent; color: var(--foreground); border: 0; }
.search:focus-within, .dialog input:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.links { margin-top: 12px; overflow: auto; }
.links p, .local, .top, .content p, .tools small, .dialog p, .options small { color: var(--muted-foreground); }
.links p { margin: 24px 8px 5px; font-size: 12px; }
.local { margin-top: auto; padding: 12px 5px 0; border-top: 1px solid var(--border); }
.main { flex: 1; min-width: 0; background-image: radial-gradient(var(--border) .75px, transparent .75px); background-size: 24px 24px; }
.top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 12px 16px; }
.top > div { display: flex; gap: 8px; align-items: center; padding: 2px 12px 2px 3px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow-wrap: anywhere; }
.content, .status { max-width: 840px; margin: clamp(30px, 7vh, 90px) auto; padding: 0 clamp(15px, 4vw, 44px); }
.intro { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: clamp(20px, 4vw, 44px); }
.intro > svg { color: var(--primary); }
.intro h1 { margin: 9px 0 14px; font-size: clamp(25px, 4vw, 42px); line-height: 1.2; overflow-wrap: anywhere; }
.intro p { margin: 8px 0; line-height: 1.55; }
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 8px 15px; border-radius: 7px; background: var(--primary); color: var(--primary-foreground); }
.primary:hover:not(:disabled) { background: var(--primary-hover); }
.intro .primary { margin-top: 15px; }
.content h2 { font-size: 16px; margin: 28px 0 12px; }
.tools { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.tools a, .options button { display: flex; align-items: center; gap: 12px; padding: 16px; text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.tools a:hover, .options button:hover { border-color: var(--selection-border); background: var(--row-hover); }
.tools a svg, .options svg { flex-shrink: 0; }
.tools a svg:last-child { margin-left: auto; }
.tools small, .options small { display: block; margin-top: 5px; line-height: 1.5; }
.dialog { width: min(535px, calc(100% - 24px)); max-height: calc(100dvh - 24px); padding: clamp(18px, 4vw, 32px); color: var(--foreground-strong); background: var(--surface); border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 18px 48px var(--shadow); }
.dialog::backdrop { background: var(--scrim); }
.dialogTop { display: flex; align-items: center; gap: 6px; }
.dialogTop h2 { flex: 1; font-size: clamp(18px, 3vw, 24px); margin: 0; }
.dialog p { line-height: 1.55; }
.options { display: grid; gap: 12px; margin: 24px 0; }
.options button { min-height: 74px; }
.options button[aria-pressed=true] { background: var(--selection); border-color: var(--primary); }
.dialog .primary { width: 100%; }
.iconPreview { display: flex; align-items: center; gap: 10px; margin: 22px 0; }
.iconPreview .avatar { width: 48px; height: 48px; font-size: 20px; }
.dialog input { width: 100%; min-height: 42px; margin: 8px 0 20px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--foreground-strong); }
.dialog input[aria-invalid=true] { border-color: var(--danger); margin-bottom: 0; }
.dialog .error { color: var(--danger); }
.notice { position: fixed; right: 16px; bottom: 16px; z-index: 4; max-width: calc(100vw - 32px); overflow-wrap: anywhere; padding: 14px 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px var(--shadow); }
@media (max-width: 760px) { .panel { position: fixed; left: 50px; width: min(248px, calc(100% - 50px)); z-index: 3; box-shadow: 8px 0 20px var(--shadow); } .rail { z-index: 4; } .top { position: sticky; top: 0; z-index: 4; margin-left: 8px; } .app:has(.panel) .top { z-index: 2; } .tools { grid-template-columns: 1fr; } }