Show recent canvas edits on the workspace landing page
This commit is contained in:
@@ -17,7 +17,7 @@ export default function InfiniteCanvas(){
|
||||
const pickerSequence=useRef(0),agent=useRef<CanvasAgentHandle>(null);
|
||||
const active=doc.cards.find(c=>c.id===selected);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
|
||||
useEffect(()=>{if(!ready)return;try{const serialized=JSON.stringify(doc);const previous=localStorage.getItem(key);if(previous!==serialized){localStorage.setItem(key,serialized);if(previous||doc.cards.length)localStorage.setItem(key+'.editedAt',new Date().toISOString());}setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
|
||||
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
|
||||
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setRefMenu('');setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
|
||||
|
||||
@@ -18,7 +18,7 @@ const purposes = [
|
||||
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
|
||||
];
|
||||
type Space = {name: string; purpose: string};
|
||||
type Subcanvas = {id: string; name: string; nodes: string[]};
|
||||
type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string};
|
||||
const personal = {name: '个人空间', purpose: ''};
|
||||
|
||||
export default function Workspace() {
|
||||
@@ -35,7 +35,8 @@ export default function Workspace() {
|
||||
const [notice, setNotice] = useState('');
|
||||
const [nodes, setNodes] = useState<CanvasNode[]>([]);
|
||||
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
|
||||
const [selectedCanvas, setSelectedCanvas] = useState('');
|
||||
const [selectedCanvas, setSelectedCanvas] = useState('history');
|
||||
const [draftEditedAt, setDraftEditedAt] = useState<string>();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [subsetName, setSubsetName] = useState('');
|
||||
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
|
||||
@@ -85,10 +86,11 @@ export default function Workspace() {
|
||||
const subsetKey = 'studio.subcanvases:' + email + ':' + active.name;
|
||||
useEffect(() => {
|
||||
if (!email) return;
|
||||
setSelectedCanvas('');
|
||||
setSelectedCanvas('history');
|
||||
setQuery('');
|
||||
try {
|
||||
const draft = localStorage.getItem('studio.infinite-canvas.v1');
|
||||
setDraftEditedAt(localStorage.getItem('studio.infinite-canvas.v1.editedAt') || undefined);
|
||||
setNodes(draft ? parseCanvasDocument(draft).cards : []);
|
||||
} catch {
|
||||
setNodes([]);
|
||||
@@ -113,7 +115,7 @@ export default function Workspace() {
|
||||
const name = subsetName.trim();
|
||||
if (!name || !subsetNodes.length) return setError('请输入名称并选择至少一个节点。');
|
||||
const id = selectedCanvas || window.crypto?.randomUUID?.() || String(Date.now());
|
||||
const subset = {id, name, nodes: subsetNodes};
|
||||
const subset = {id, name, nodes: subsetNodes, editedAt: new Date().toISOString()};
|
||||
const next = [...subcanvases.filter(item => item.id !== id), subset];
|
||||
try {
|
||||
localStorage.setItem(subsetKey, JSON.stringify(next));
|
||||
@@ -163,6 +165,21 @@ export default function Workspace() {
|
||||
const visibleNodes = subset ? nodes.filter(node => subset.nodes.includes(node.id)) : nodes;
|
||||
const visibleSubsets = subcanvases.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
const canvasName = '无限画布';
|
||||
const showHistory = selectedCanvas === 'history';
|
||||
const dates = new Intl.DateTimeFormat('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'});
|
||||
const times = new Intl.DateTimeFormat('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false});
|
||||
const entries = [
|
||||
...(nodes.length || draftEditedAt ? [{id: '', name: canvasName, editedAt: draftEditedAt, summary: nodes[0]?.text || nodes[0]?.title || '本机草稿'}] : []),
|
||||
...subcanvases.map(item => ({...item, summary: nodes.find(node => item.nodes.includes(node.id))?.text || '子画布'})),
|
||||
].filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
entries.sort((a, b) => (b.editedAt ?? '').localeCompare(a.editedAt ?? ''));
|
||||
const history = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const day = entry.editedAt && Number.isFinite(Date.parse(entry.editedAt)) ? dates.format(new Date(entry.editedAt)) : '编辑时间未记录';
|
||||
const group = history.get(day) ?? [];
|
||||
group.push(entry);
|
||||
history.set(day, group);
|
||||
}
|
||||
return <div className={s.app}>
|
||||
<Head><title>工作台 · 啡跃</title></Head>
|
||||
<nav className={s.rail} aria-label="全局导航">
|
||||
@@ -194,10 +211,11 @@ export default function Workspace() {
|
||||
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><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>
|
||||
{state === 'ready' && <nav className={s.links} aria-label="画布树">
|
||||
<button className={s.historyLink} type="button" aria-current={showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('history')}>编辑历史</button>
|
||||
<p>本机画布</p>
|
||||
<div className={s.treeRoot}>
|
||||
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
|
||||
<button type="button" aria-current={!subset ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
|
||||
<button type="button" aria-current={!subset && !showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
|
||||
</div>
|
||||
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
|
||||
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
|
||||
@@ -206,11 +224,23 @@ export default function Workspace() {
|
||||
</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} / {subset?.name ?? canvasName}</span></div>
|
||||
<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} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}</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}>
|
||||
showHistory ? <section className={s.history} aria-labelledby="history-heading">
|
||||
<header><div><h1 id="history-heading">编辑历史</h1><p>按最近编辑日期排列</p></div><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></header>
|
||||
{Array.from(history, ([day, items]) => <section className={s.historyGroup} key={day}>
|
||||
<h2>{day} · {items.length}</h2>
|
||||
<ul>{items.map(item => <li key={item.id}><button type="button" onClick={() => setSelectedCanvas(item.id)}>
|
||||
{item.id ? <LayoutGrid size={18}/> : <Layers size={18}/>}
|
||||
<span className={s.historyItem}><strong>{item.name}</strong><small>{item.summary}</small></span>
|
||||
{item.editedAt && Number.isFinite(Date.parse(item.editedAt)) && <time dateTime={item.editedAt}>{times.format(new Date(item.editedAt))}</time>}
|
||||
<ChevronRight size={16}/>
|
||||
</button></li>)}</ul>
|
||||
</section>)}
|
||||
{!entries.length && <p className={s.historyEmpty}>{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}</p>}
|
||||
</section> : <div className={s.content}>
|
||||
<section className={s.intro}>
|
||||
<Layers size={20}/>
|
||||
<h1>{subset?.name ?? canvasName}</h1>
|
||||
|
||||
@@ -83,3 +83,22 @@
|
||||
.members { display: grid; gap: 8px; max-height: 30vh; overflow: auto; margin: 0 0 16px; border: 1px solid var(--border); }
|
||||
.members label { display: flex; align-items: center; gap: 8px; overflow-wrap: anywhere; }
|
||||
.members input[type=checkbox] { width: auto; min-height: 0; margin: 0; accent-color: var(--primary); }
|
||||
.historyLink { width: 100%; padding: 9px; text-align: left; border-radius: 6px; }
|
||||
.historyLink[aria-current] { background: var(--selection); }
|
||||
.historyLink:hover { background: var(--row-hover); }
|
||||
.history { margin: 12px 16px 24px; padding: clamp(16px, 3vw, 38px); min-height: calc(100dvh - 92px); background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.history > header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 26px; }
|
||||
.history h1 { margin: 0; font-size: 23px; }
|
||||
.history header p, .historyGroup h2, .historyItem small, .history time, .historyEmpty { color: var(--muted-foreground); }
|
||||
.history header p { margin: 6px 0 0; }
|
||||
.historyGroup { margin-top: 22px; }
|
||||
.historyGroup h2 { font-size: 14px; font-weight: 500; margin: 0 0 6px; }
|
||||
.historyGroup ul { list-style: none; padding: 0; margin: 0; }
|
||||
.historyGroup button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px; min-height: 52px; border-radius: 7px; text-align: left; }
|
||||
.historyGroup button:hover { background: var(--row-hover); }
|
||||
.historyGroup button > svg { flex-shrink: 0; color: var(--muted-foreground); }
|
||||
.historyItem { flex: 1; min-width: 0; }
|
||||
.historyItem strong, .historyItem small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.historyItem small { margin-top: 4px; }
|
||||
.history time { font-variant-numeric: tabular-nums; }
|
||||
.historyEmpty { padding: 18px 0; line-height: 1.65; }
|
||||
|
||||
Reference in New Issue
Block a user