Merge latest dashboard and Turbo setup into dev while preserving workspace features

This commit is contained in:
2026-11-04 23:16:28 +08:00
383 changed files with 3400 additions and 18527 deletions
+4
View File
@@ -0,0 +1,4 @@
/** The approved mark is copied from @endoleap/brand during the remote build. */
export function Brand({ compact = false, markOnly = false }: { compact?: boolean; markOnly?: boolean }) {
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/>{!markOnly && <span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span>}</span>;
}
@@ -0,0 +1,495 @@
import Link from "next/link";
import { useRouter } from "next/router";
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import {
ArrowRight,
ArrowUpRight,
Bot,
ChevronRight,
FileText,
Plus,
Search,
X,
} from "lucide-react";
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
import {
EmptyState,
KindBadge,
KindIcon,
Notice,
PageHeading,
kindNames,
Tabs,
} from "@/components/GuidanceUI";
import { guides, type Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
type Summary = {
id: string;
revision: number;
title: string;
summary: string;
kind: Guidance["kind"];
author?: string;
tags?: string[];
source: "community" | "example";
};
type Source = "all" | "published" | "examples";
const kinds = ["all", "guide", "context", "evidence"] as const;
const examples: Summary[] = guides.map((g) => ({ ...g, source: "example" }));
export default function DocumentDirectory({
communityOnly = false,
}: {
communityOnly?: boolean;
}) {
const router = useRouter();
const mine = communityOnly && router.query.scope === "mine";
return (
<GuidanceShell
title={mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档"}
>
<DirectoryContent communityOnly={communityOnly} mine={mine} />
</GuidanceShell>
);
}
function DirectoryContent({
communityOnly,
mine,
}: {
communityOnly: boolean;
mine: boolean;
}) {
const router = useRouter(),
account = useGuidanceAccount();
const [published, setPublished] = useState<Summary[]>([]),
[loading, setLoading] = useState(true),
[error, setError] = useState("");
const [q, setQ] = useState(""),
[kind, setKind] = useState("all"),
[source, setSource] = useState<Source>(communityOnly ? "published" : "all"),
[sort, setSort] = useState("default");
const controller = useRef<AbortController | null>(null);
const refresh = useCallback(async () => {
controller.current?.abort();
const abort = new AbortController();
controller.current = abort;
setLoading(true);
setError("");
try {
const r = await fetch(
"/api/documents" +
(mine && account.user ? "?author=" + account.user.id : ""),
{ signal: abort.signal },
);
const b = await r.json();
if (!r.ok) throw Error(b.error || "目录暂时不可用");
if (!Array.isArray(b.documents)) throw Error("目录返回异常,请稍后重试");
setPublished(
b.documents.map((d: Omit<Summary, "source">) => ({
...d,
source: "community",
})),
);
} catch (e) {
if (!abort.signal.aborted)
setError(e instanceof Error ? e.message : "目录暂时不可用");
} finally {
if (!abort.signal.aborted) setLoading(false);
}
}, [mine, account.user?.id]);
useEffect(() => {
void refresh();
return () => controller.current?.abort();
}, [refresh]);
useEffect(() => {
if (!router.isReady) return;
setQ(typeof router.query.q === "string" ? router.query.q : "");
setKind(
typeof router.query.kind === "string" &&
kinds.some((k) => k === router.query.kind)
? router.query.kind
: "all",
);
setSource(
communityOnly
? "published"
: router.query.source === "published" ||
router.query.source === "examples"
? router.query.source
: "all",
);
setSort(router.query.sort === "title" ? "title" : "default");
}, [
router.isReady,
router.query.q,
router.query.kind,
router.query.source,
router.query.sort,
communityOnly,
]);
function remember(
next: { q?: string; kind?: string; source?: Source; sort?: string } = {},
) {
const values = { q, kind, source, sort, ...next },
query: Record<string, string> = {};
if (values.q) query.q = values.q;
if (values.kind !== "all") query.kind = values.kind;
if (!communityOnly && values.source !== "all") query.source = values.source;
if (values.sort !== "default") query.sort = values.sort;
if (mine) query.scope = "mine";
void router.push(
{ pathname: communityOnly ? "/documents" : "/", query },
undefined,
{ shallow: true, scroll: false },
);
}
function search(e: FormEvent) {
e.preventDefault();
remember();
}
function clear() {
setQ("");
setKind("all");
setSource(communityOnly ? "published" : "all");
setSort("default");
remember({
q: "",
kind: "all",
source: communityOnly ? "published" : "all",
sort: "default",
});
}
const all: Summary[] = communityOnly
? published
: [...published, ...examples];
const term = q.trim().toLocaleLowerCase();
const found = all.filter(
(d) =>
(!mine || d.author === account.user?.id) &&
(source === "all" ||
(source === "published" && d.source === "community") ||
(source === "examples" && d.source === "example")) &&
`${d.title} ${d.summary} ${d.id} ${d.tags?.join(" ") || ""}`
.toLocaleLowerCase()
.includes(term),
);
const documents = found.filter((d) => kind === "all" || d.kind === kind);
if (sort === "title")
documents.sort((a, b) => a.title.localeCompare(b.title, "zh-CN"));
const waiting = loading || (mine && account.loading),
filtered =
!!q ||
kind !== "all" ||
(!communityOnly && source !== "all") ||
sort !== "default";
const heading = mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档";
return (
<>
<PageHeading
title={heading}
description={
mine
? "在这里管理你发布的文档,修改会创建新的固定版本。"
: communityOnly
? "来自真实作者的独立文档。找到有用的知识,从一篇开始阅读。"
: "为当前任务找到有用的指导,按需连接下一篇知识。"
}
eyebrow={
mine
? "YOUR KNOWLEDGE"
: communityOnly
? "COMMUNITY LIBRARY"
: "DISCOVER KNOWLEDGE"
}
>
<Link
className={s.secondaryButton}
href="/agent/index.md"
target="_blank"
>
<Bot size={15} />
Agent 入口
<ArrowUpRight size={13} />
</Link>
</PageHeading>
{!communityOnly && (
<>
<section className={s.introCard}>
<span className={s.introIcon}>
<Bot size={23} />
</span>
<div>
<h2>好用的知识,从不必整套加载。</h2>
<p>
每篇文档都有适用条件和固定版本。需要更多时,再沿着有用的链接继续阅读。
</p>
</div>
<div className={s.introDiagram} aria-hidden="true">
<span>
<FileText size={13} />
一篇文档
</span>
<ArrowRight size={15} />
<span>
<FileText size={13} />
下一页
</span>
</div>
<Link className={s.textLink} href="/protocol">
了解工作原理
<ArrowUpRight size={13} />
</Link>
</section>
<div className={s.topics}>
<span>试试这些任务</span>
{["代码审查", "规划", "验证"].map((t) => (
<button
type="button"
key={t}
onClick={() => {
setQ(t);
remember({ q: t });
}}
>
{t}
</button>
))}
</div>
</>
)}
{mine && !account.loading && !account.user ? (
<section className={s.panel}>
{account.error && (
<div className={s.panelBody}>
<Notice tone="error">
{account.error}{" "}
<button type="button" onClick={() => void account.refresh()}>
重新检查
</button>
</Notice>
</div>
)}
<EmptyState
title="登录后,管理你的知识"
description="使用已验证的邮箱账户发布文档、更新版本,并随时回到这里继续编辑。"
>
<Link
className={s.primaryButton}
href="/account?next=%2Fdocuments%3Fscope%3Dmine"
>
登录 / 创建账户
<ArrowRight size={15} />
</Link>
</EmptyState>
</section>
) : (
<>
<section className={s.panel} aria-label="文档目录">
<Tabs
tabs={kinds.map((k) => ({
id: k,
label: k === "all" ? "全部文档" : kindNames[k],
count: found.filter((d) => k === "all" || d.kind === k).length,
}))}
value={kind}
onChange={(value) => {
setKind(value);
remember({ kind: value });
}}
/>
<div className={s.catalogToolbar}>
<form className={s.catalogSearch} role="search" onSubmit={search}>
<Search size={16} />
<input
aria-label="搜索文档"
placeholder="搜索标题、摘要或文档 ID…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{q && (
<button
type="button"
aria-label="清空关键词"
onClick={() => {
setQ("");
remember({ q: "" });
}}
>
<X size={14} />
</button>
)}
<button type="submit" aria-label="提交搜索">
<ArrowRight size={15} />
</button>
</form>
<div className={s.catalogTools}>
{!communityOnly && (
<select
aria-label="文档来源"
value={source}
onChange={(e) => {
const value = e.target.value as Source;
setSource(value);
remember({ source: value });
}}
>
<option value="all">全部来源</option>
<option value="published">社区发布</option>
<option value="examples">协议样例</option>
</select>
)}
<select
aria-label="文档排序"
value={sort}
onChange={(e) => {
setSort(e.target.value);
remember({ sort: e.target.value });
}}
>
<option value="default">默认顺序</option>
<option value="title">按标题排序</option>
</select>
</div>
</div>
<div className={s.catalogStatus}>
<p role="status">
{waiting
? "正在加载文档…"
: error
? `当前显示 ${documents.length} 篇,社区目录加载失败`
: `找到 ${documents.length} 篇文档`}
</p>
{filtered ? (
<button type="button" aria-label="清除所有筛选" onClick={clear}>
清除所有筛选
</button>
) : (
<span>最多展示最近 100 篇社区文档</span>
)}
</div>
{error && (
<div className={s.panelBody}>
<Notice tone="error">
{error}{" "}
<button
type="button"
className={s.textLink}
onClick={() => void refresh()}
>
重新加载
</button>
</Notice>
</div>
)}
<div className={s.tableHeader} aria-hidden="true">
<span>文档</span>
<span>类型</span>
<span>版本</span>
<span className={s.tableSource}>来源</span>
<span />
</div>
{waiting && (!documents.length || communityOnly) ? (
<div aria-label="正在加载文档" aria-busy="true">
{[0, 1, 2].map((i) => (
<div className={s.skeletonRow} key={i}>
<div className={s.skeletonBox} />
<div className={s.skeletonLines}>
<div className={s.skeletonLine} />
<div className={s.skeletonLine} />
</div>
</div>
))}
</div>
) : (
documents.map((d) => (
<Link
href={`${d.source === "example" ? "/guide/" : "/documents/"}${d.id}?revision=${d.revision}`}
key={d.id}
className={s.documentRow}
>
<div className={s.documentPrimary}>
<span className={s.documentIcon}>
<KindIcon kind={d.kind} />
</span>
<div className={s.documentDetails}>
<h2 className={s.documentTitle}>{d.title}</h2>
<p className={s.documentSummary}>{d.summary}</p>
<p className={s.documentId} title={d.id}>
{d.id}
</p>
<div className={s.documentMobileMeta}>
<KindBadge kind={d.kind} />
<span>v{d.revision}</span>
<span>
{d.source === "example" ? "协议样例" : "社区发布"}
</span>
</div>
</div>
</div>
<span className={s.documentKind}>
<KindBadge kind={d.kind} />
</span>
<span className={s.documentVersion}>v{d.revision}</span>
<span className={s.documentSource}>
{d.source === "example" ? "协议样例" : "社区发布"}
</span>
<ChevronRight size={16} className={s.documentArrow} />
</Link>
))
)}
{!waiting && !documents.length && !error && (
<EmptyState
title={
filtered
? "没有匹配文档"
: mine
? "你的第一篇文档,从这里开始"
: "这里还没有社区文档"
}
description={
filtered
? "试试其他关键词或清除筛选,发现更多适合当前任务的知识。"
: mine
? "把你反复使用的方法写下来,让 Agent 在需要时找到它。"
: "你可以先阅读协议样例,也可以发布第一篇独立文档。"
}
>
{filtered ? (
<button
type="button"
className={s.secondaryButton}
onClick={clear}
>
重置筛选
</button>
) : (
<Link className={s.primaryButton} href="/write">
<Plus size={15} />
发布一篇文档
</Link>
)}
</EmptyState>
)}
</section>
<div className={s.catalogFootnote}>
<span>
{mine
? "只有你可以更新自己的文档,已发布的历史版本保持不变。"
: "社区文档与协议样例分别标识,采用前请确认适用条件与版本。"}
</span>
<Link href="/protocol">
了解读取规范
<ArrowUpRight size={12} />
</Link>
</div>
</>
)}
</>
);
}
@@ -0,0 +1,49 @@
import Head from 'next/head';
import Link from 'next/link';
import {useEffect,useRef,useState} from 'react';
import {useRouter} from 'next/router';
import {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText,Pencil,Archive,ArchiveRestore} from 'lucide-react';
import type {ExploreEntry} from '@/lib/explore';
import {previewUrl,exploreUrl} from '@/content/page-links';
import {Shell} from './Shell';
import {ExploreFeed} from './ExploreFeed';
import {NoteMarkdown} from './NoteMarkdown';
import {QuickTags} from './QuickTags';
import {NoteEditor} from './NoteEditor';
export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;background:ExploreEntry[]}){
const [entry,setEntry]=useState(initial),[index,setIndex]=useState(0),[copied,setCopied]=useState(''),[editing,setEditing]=useState(false),[copyError,setCopyError]=useState(''),[archiveBusy,setArchiveBusy]=useState(false);
const router=useRouter();const closeRef=useRef<HTMLButtonElement>(null),detailRef=useRef<HTMLDivElement>(null);const media=entry.media[index];
useEffect(()=>{setEntry(initial);setIndex(0);setCopied('');setEditing(false)},[initial]);
const close=()=>{if(editing){setEditing(false);return;}void router.push('/')};
useEffect(()=>{
const previous=document.activeElement as HTMLElement|null;if(!editing)closeRef.current?.focus();const old=document.body.style.overflow;document.body.style.overflow='hidden';
function key(e:KeyboardEvent){
if(e.key==='Escape'){e.preventDefault();close();return;}
const typing=e.target instanceof HTMLElement&&!!e.target.closest('input,textarea,select,[contenteditable="true"]');
if(!editing&&!typing&&e.key==='ArrowLeft')setIndex(n=>Math.max(0,n-1));
if(!editing&&!typing&&e.key==='ArrowRight')setIndex(n=>Math.max(0,Math.min(entry.media.length-1,n+1)));
if(e.key==='Tab'){const items=Array.from(detailRef.current?.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),summary,video,audio')||[]).filter(el=>el.getClientRects().length);if(!items.length)return;const first=items[0],last=items[items.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}
}
window.addEventListener('keydown',key);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',key);previous?.focus()};
},[entry.id,editing]);
async function copy(mode:'web'|'agent'){
try{const url=window.location.origin+exploreUrl(entry.id)+(mode==='agent'?'?agent_mode=True':'');
if(navigator.clipboard)await navigator.clipboard.writeText(url);else{const input=document.createElement('textarea');input.value=url;document.body.appendChild(input);input.select();const success=document.execCommand('copy');input.remove();if(!success)throw Error('Copy failed');}
setCopied(mode);setCopyError('');
}catch{setCopied('');setCopyError('复制失败,请重试。')}
}
async function archive(){
setArchiveBusy(true);setCopyError('');try{const r=await fetch('/api/posts/'+entry.id+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({archived:!entry.archived})});const d=await r.json();if(!r.ok)throw Error(d.error||'操作失败');const state=await fetch('/api/notes/'+entry.id).then(r=>r.json());setEntry(state.entry);void router.replace(router.asPath,undefined,{scroll:false})}catch(e){setCopyError(e instanceof Error?e.message:'操作失败')}finally{setArchiveBusy(false)}
}
return <Shell><Head><title>{entry.title} · 啡跃 Endoleap</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
<div aria-hidden="true" inert><ExploreFeed key={entry.revision} initial={background.map(e=>e.id===entry.id?entry:e)}/></div>
<div className="note-overlay" onClick={()=>{if(!editing)close()}}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}>
<button className="note-close" ref={closeRef} aria-label={editing?'取消编辑':'关闭详情'} onClick={close}><X/></button>
<section className={'note-stage '+(!media?'note-text-stage':'')}>
{media?.kind==='image'?<img key={media.url} src={previewUrl(media.url,1280)} alt={media.title} decoding="async"/>:media?.kind==='video'?<video controls playsInline preload="metadata" src={media.url} poster={media.poster?previewUrl(media.poster,960):undefined}/>:media?.kind==='audio'?<div className="note-audio"><span>♫</span><audio controls preload="metadata" src={media.url}/></div>:<div className="note-title-cover" style={{background:entry.color}}><small>{entry.namespace}</small><strong>{entry.title}</strong><span>灵感笔记</span></div>}
{entry.media.length>1&&<><button className="note-prev" aria-label="上一张" disabled={index===0} onClick={()=>setIndex(n=>n-1)}><ChevronLeft/></button><button className="note-next" aria-label="下一张" disabled={index===entry.media.length-1} onClick={()=>setIndex(n=>n+1)}><ChevronRight/></button><div className="note-dots">{entry.media.map((_,i)=><button key={i} className={i===index?'selected':''} aria-label={'查看第 '+(i+1)+' 张'} aria-pressed={i===index} onClick={()=>setIndex(i)}/>)}</div><span className="note-count">{index+1} / {entry.media.length}</span></>}
</section><section className="note-info"><div className="note-content">
{editing?<NoteEditor key={entry.id} entry={entry} onCancel={()=>setEditing(false)} onSaved={updated=>{setEntry(updated);setEditing(false);setCopied('')}}/>:<><h1 id="note-title">{entry.title}</h1>{entry.archived&&<p className="note-archive-status" role="status">已归档 · 已从列表和搜索中隐藏,内容仍保留。</p>}<p className="note-description">{entry.description}</p><div className="note-tags">{entry.systemTags?.map(t=><Link className="system-tag-pill" key={t.id} href={"/categories?category="+encodeURIComponent(t.name)}>{t.name}</Link>)}{entry.categories.map(c=><Link key={c} href={'/categories?category='+encodeURIComponent(c)}>#{c} </Link>)}<QuickTags entry={entry} onSaved={setEntry}/></div><NoteMarkdown text={entry.markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'')}/><section id="backlinks" className="note-backlinks"><h2>被 {entry.backlinkCount??0} 篇笔记引用</h2>{entry.backlinks?.length?<ul>{entry.backlinks.map(ref=><li key={ref.id}><Link href={exploreUrl(ref.id)}>{ref.title}</Link></li>)}</ul>:<p>还没有笔记引用本文。</p>}</section><div className="note-id">内容 ID · {entry.id}</div></>}
</div>{!editing&&<footer><button onClick={()=>void copy('web')}><LinkIcon size={18}/>{copied==='web'?'网页链接已复制':'复制网页链接'}</button><button onClick={()=>void copy('agent')}><FileText size={18}/>{copied==='agent'?'纯文本链接已复制':'复制纯文本链接'}</button><button onClick={()=>setEditing(true)}><Pencil size={17}/>编辑</button>{entry.postSlug&&<button disabled={archiveBusy} onClick={()=>void archive()}>{entry.archived?<ArchiveRestore size={17}/>:<Archive size={17}/>}{archiveBusy?'处理中…':entry.archived?'恢复':'归档'}</button>}{copyError&&<span role="status">{copyError}</span>}</footer>}</section>
</div></div></Shell>;
}
@@ -0,0 +1,18 @@
import Link from 'next/link';
import {useState} from 'react';
import {Images,Link2} from 'lucide-react';
import type {ExploreEntry} from '@/lib/explore';
import {QuickTags} from './QuickTags';
import {exploreUrl,previewUrl} from '@/content/page-links';
export function ExploreFeed({initial,cursor:initialCursor=null,q='',category='',total}:{initial:ExploreEntry[];cursor?:string|null;q?:string;category?:string;total?:number}){
const [entries,setEntries]=useState(initial);const [cursor,setCursor]=useState(initialCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
async function more(){setBusy(true);try{const res=await fetch('/api/explore-feed?'+new URLSearchParams({q,category,cursor:cursor||''}));if(!res.ok)throw Error();const data=await res.json();setEntries(old=>[...old,...data.entries.filter((e:ExploreEntry)=>!old.some(o=>o.id===e.id))]);setCursor(data.nextCursor);setError('');}catch{setError('加载失败,点击重试')}finally{setBusy(false)}}
return <>{q&&<div className="search-results-heading" role="status">搜索「{q}」{total!==undefined?` · ${total} 篇`:""}<span>按匹配程度、被引用数排序</span></div>}<div className="discovery-grid">{entries.map((entry,i)=><article key={entry.id} className="discovery-card">
<Link href={exploreUrl(entry.id)} className="discovery-note-link"><div className="discovery-cover" style={{background:entry.color,aspectRatio:'4 / 5'}}>
{entry.media[0]?<img src={previewUrl(entry.media[0].poster||entry.media[0].url,480)} alt={entry.media[0].title} loading={i<4?'eager':'lazy'} decoding="async"/>:<><small>{entry.namespace.toUpperCase()}</small><strong>{entry.title}</strong><span>记录 · 连接 · 灵感</span></>}
{!!entry.systemTags?.length&&<span className="discovery-system-tags">{entry.systemTags.map(t=><span key={t.id}>{t.name}</span>)}</span>}
{entry.imageCount>1&&<span className="discovery-image-count" aria-label={`${entry.imageCount} 张图片`}><Images size={13}/>{entry.imageCount}</span>}
</div><h2>{entry.title}</h2>{q&&entry.searchExcerpt&&<p className="search-excerpt">{entry.searchExcerpt}</p>}</Link>
<div className="discovery-card-meta"><div className="discovery-tags" aria-label="普通标签">{entry.categories.slice(0,2).map(tag=><Link key={tag} href={'/categories?category='+encodeURIComponent(tag)}>#{tag}</Link>)}{entry.categories.length>2&&<span title={entry.categories.map(t=>'#'+t).join(' ')}>+{entry.categories.length-2}</span>}</div><Link className="backlink-count" href={exploreUrl(entry.id)+"#backlinks"} aria-label={`被 ${entry.backlinkCount??0} 篇笔记引用`}><Link2 size={14}/><span>{entry.backlinkCount??0}</span></Link><QuickTags entry={entry} onSaved={updated=>setEntries(old=>old.map(e=>e.id===entry.id?updated:e).filter(e=>!category||e.categories.includes(category)||e.systemTags?.some(t=>t.name===category||t.system_key===category)||e.namespace===category))}/></div>
</article>)}</div>{!entries.length&&<p className="discovery-empty">{q?'没有匹配的内容,试试更短的关键词。':'这个分类还没有内容。'}</p>}{cursor&&<div className="load-more"><button disabled={busy} onClick={more}>{busy?'加载中…':error||'再看一些'}</button></div>}</>;
}
+42
View File
@@ -0,0 +1,42 @@
import {previewUrl} from '@/content/page-links';
import {SkillCard} from "./SkillCard";
import {skillCatalog} from "@/content/skills/catalog";
import Head from "next/head";
import Link from "next/link";
import { useEffect,useRef,useState } from "react";
import { useRouter } from "next/router";
import { Search,Image as ImageIcon,ArrowUpRight,AudioLines,Film,Layers,ArrowDown,Upload } from "lucide-react";
import { Shell } from "./Shell";
import { sections,mediaUrl,type FeedData,type Post } from "@/lib/model";
export function PostCard({post}:{post:Post}){
const image=post.assets.find(a=>a.kind==='image');const video=post.assets.find(a=>a.kind==='video');const cover=post.cover_asset_id||image?.id;
const label=sections.find(s=>s.kind===post.kind)?.name;const count=post.assets.filter(a=>a.kind!=='text').length;
const source=cover?mediaUrl(cover):video?.variants.includes('poster')?mediaUrl(video.id,'poster'):null;
return <Link className="post-card" href={`/explore/${post.id}`}><div className={`post-cover cover-${post.kind}`} style={image?.width&&image?.height?{aspectRatio:`${image.width}/${image.height}`,maxHeight:460}:undefined}>
{source?<img src={previewUrl(source,480)} alt={post.title} loading="lazy"/>:post.kind==='audio'?<div className="waveform">{Array.from({length:36},(_,i)=><i key={i} style={{height:12+((i*31)%65)}}/>)}</div>:<div className="cover-symbol">{post.kind==='video'?<Film size={42}/>:<Layers size={42}/>}<span>{label}</span></div>}
{count>1&&<span className="cover-count"><Layers size={12}/>{count}</span>}{video&&<span className="cover-badge">{video.has_audio?'有声音':'无声音'}</span>}
</div><h3 title={post.title}>{post.title}</h3><div className="post-meta"><span><i className={`type-dot ${post.kind}`}/>{label}</span><time>{new Date(post.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit',timeZone:'Asia/Shanghai'})}</time></div></Link>;
}
function Masonry({posts,includeSkills=false,q=""}:{posts:Post[];includeSkills?:boolean;q?:string}){
const element=useRef<HTMLDivElement>(null);const [width,setWidth]=useState(1100);
useEffect(()=>{if(!element.current)return;const observer=new ResizeObserver(entries=>setWidth(entries[0].contentRect.width));observer.observe(element.current);return ()=>observer.disconnect();},[]);
const count=width>1250?5:width>1000?4:width>720?3:2;
type Item={post?:Post;skill?:typeof skillCatalog[number];date:string};const columns:Item[][]=Array.from({length:count},()=>[]);const items:Item[]=[...posts.map(post=>({post,date:post.created_at})),...(includeSkills?skillCatalog.filter(s=>(s.title+s.description+s.category).toLowerCase().includes(q.toLowerCase())).map(skill=>({skill,date:skill.created_at})):[])].sort((a,b)=>b.date.localeCompare(a.date));const heights=Array(count).fill(0);const cardWidth=(width-(width<=720?14:20)*(count-1))/count;
for(const item of items){const image=item.post?.assets.find(a=>a.kind==='image');const ratio=item.skill?1.25:image?.width&&image.height?image.height/image.width:item.post?.kind==='audio'?1:1.25;const index=heights.indexOf(Math.min(...heights));columns[index].push(item);heights[index]+=Math.min(cardWidth*ratio,item.skill?440:460)+78;}
return <div ref={element} className="masonry" style={{gridTemplateColumns:`repeat(${count}, minmax(0,1fr))`}}>{columns.map((items,i)=><div className="masonry-column" key={i}>{items.map(item=>item.skill?<SkillCard skill={item.skill} key={item.skill.id}/>:<PostCard post={item.post!} key={item.post!.id}/>)}</div>)}</div>;
}
export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:string;tagPath?:string}){
const router=useRouter();const [posts,setPosts]=useState(initial.posts);const [cursor,setCursor]=useState(initial.nextCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const generation=useRef(0);
useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]);
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags;
async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}}
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · 啡跃 Endoleap</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
{current&&<header className="page-header feed-heading"><div><h1>{target?.name||current.name}</h1><p>{current.hint}</p></div></header>}
{tagPath&&<div className="breadcrumbs"><Link href={`/${section}`}>全部{current?.name}</Link>{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return <span key={path}> / <Link href={`/${section}/${path}`}>{initial.tags.find(t=>t.path===path)?.name}</Link></span>;})}</div>}
<div className="feed-toolbar"><div className="feed-tabs">{!section?<><Link className="selected" href="/">全部</Link>{sections.map(s=><Link key={s.path} href={`/${s.path}`}>{s.name}</Link>)}</>:<><Link className={!tagPath?'selected':''} href={`/${section}`}>全部</Link>{children.map(t=><Link key={t.id} href={`/${section}/${t.path}`}>{t.name}</Link>)}</>}</div><span className="sort-label"><ArrowDown size={13}/>最近收录</span></div>
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:(posts.length||(!section&&skillCatalog.some(s=>(s.title+s.description+s.category).toLowerCase().includes(String(router.query.q||'').toLowerCase()))))?<Masonry posts={posts} includeSkills={!section} q={String(router.query.q||'')}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
{cursor&&<div className="load-more"><button disabled={busy} onClick={load}>{busy?'正在翻找…':error||'再看一些'}<ArrowDown size={14}/></button></div>}
<footer className="page-footer">啡跃 <span>·</span> Endoleap</footer>
</Shell>;
}
@@ -0,0 +1,12 @@
import Head from 'next/head';
import Link from 'next/link';
import {Shell} from './Shell';
import {SkillCard} from './SkillCard';
import {skillCatalog} from '@/content/skills/catalog';
import {guidanceDocuments,humanDocumentUrl} from '@/content/skills/guidance';
export function GuidanceIndex(){return <Shell><Head><title>指导文档 · 啡跃 Endoleap</title><link rel="canonical" href="/skills"/><link rel="alternate" type="text/markdown" href="/skills/index.md" title="指导文档"/></Head>
<header className="page-header"><div><h1>指导文档</h1><p>每篇都有独立地址,网页与 Markdown 一一对应。沿着引用,找到需要的方法。</p></div></header>
<article className="docs-body"><h2>全部文档</h2><p>这些分组只帮助浏览。任何一篇都可以独立进入,也可以引用其他文档。</p><div className="guidance-directory">{guidanceDocuments.map(doc=><div key={doc.path}><Link href={humanDocumentUrl(doc.path)}>{doc.title}</Link><small>{doc.collection}</small><a href={`${humanDocumentUrl(doc.path)}/index.md`}>Markdown ↗</a></div>)}</div></article>
<h2>按创作主题浏览</h2>
<div className="skills-masonry">{skillCatalog.map(skill=><SkillCard key={skill.id} skill={skill}/>)}</div>
</Shell>}
@@ -0,0 +1,462 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
BookOpen,
Braces,
CalendarDays,
CheckSquare,
ChevronRight,
FileText,
GitBranch,
Globe2,
ListChecks,
Link2,
Pencil,
Target,
Trash2,
} from "lucide-react";
import GuidanceShell from "@/components/GuidanceShell";
import {
CopyButton,
KindBadge,
Notice,
Tabs,
kindNames,
} from "@/components/GuidanceUI";
import { guidanceXml, type Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
export type ReadingVersion = { revision: number; createdAt?: string };
export type ReadingLink = Guidance["links"][number] & { resolved: boolean };
export type ReaderProps = {
document: Guidance;
source: "example" | "community";
links: ReadingLink[];
versions: ReadingVersion[];
backlinks?: {
id: string;
revision?: number;
title: string;
source?: "example" | "community";
}[];
canEdit?: boolean;
withdrawn?: boolean;
};
const relationNames = {
guide: "可选指导",
context: "背景参考",
evidence: "事实依据",
};
function dateLabel(value?: string) {
return value
? new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: "UTC",
}).format(new Date(value))
: "协议样例";
}
export default function GuidanceReader({
document: doc,
source,
links,
versions,
backlinks = [],
canEdit = false,
withdrawn = false,
}: ReaderProps) {
const router = useRouter(),
dialog = useRef<HTMLDialogElement>(null);
const [tab, setTab] = useState("overview"),
[busy, setBusy] = useState(false),
[error, setError] = useState(""),
[isWithdrawn, setWithdrawn] = useState(withdrawn);
useEffect(() => {
setWithdrawn(withdrawn);
setError("");
setTab("overview");
}, [doc.id, withdrawn]);
const api = `/api/${source === "example" ? "guides" : "documents"}/${doc.id}?revision=${doc.revision}&format=xml`;
const current = versions.find((v) => v.revision === doc.revision),
xml = guidanceXml(doc);
const route = source === "example" ? "/guide/" : "/documents/";
const sectionLinks = [
["applies", "适用条件"],
["content", "指导正文"],
...(doc.steps.length ? [["steps", "建议步骤"]] : []),
...(doc.checks.length ? [["checks", "完成检查"]] : []),
["related", "条件链接"],
];
async function withdraw() {
setBusy(true);
setError("");
try {
const response = await fetch("/api/documents/" + doc.id, {
method: "DELETE",
});
const body = await response.json();
if (!response.ok) throw Error(body.error || "撤下失败,请稍后重试");
setWithdrawn(true);
dialog.current?.close();
} catch (e) {
setError(e instanceof Error ? e.message : "撤下失败,请稍后重试");
} finally {
setBusy(false);
}
}
return (
<GuidanceShell title="文档详情">
<header className={s.readingHeader}>
<Link
className={s.backLink}
href={source === "example" ? "/" : "/documents"}
>
<ArrowLeft size={13} />
{source === "example" ? "返回探索文档" : "返回社区文档"}
</Link>
<div className={s.documentBadges}>
<KindBadge kind={doc.kind} />
<span className={s.neutralBadge}>
{source === "example" ? "协议样例" : "社区发布"}
</span>
<span className={s.neutralBadge}>v{doc.revision}</span>
{isWithdrawn && (
<span className={s.neutralBadge}>已撤下 · 历史版本</span>
)}
</div>
<h1>{doc.title}</h1>
<p className={s.readingSummary}>{doc.summary}</p>
<div className={s.readingMeta}>
<span>
<Globe2 size={13} />
公开文档
</span>
<span>
<GitBranch size={13} />
固定版本 {doc.revision}
</span>
{current?.createdAt && (
<span>
<CalendarDays size={13} />
{dateLabel(current.createdAt)}
</span>
)}
</div>
<div className={s.readingActions}>
<CopyButton
label="复制 Agent 读取地址"
value={() => window.location.origin + api}
/>
<a
href={api}
className={s.secondaryButton}
target="_blank"
rel="noreferrer"
>
<Braces size={15} />
读取规范 XML
<ArrowUpRight size={13} />
</a>
{canEdit && !isWithdrawn && (
<Link className={s.secondaryButton} href={"/write?id=" + doc.id}>
<Pencil size={14} />
编辑最新版本
</Link>
)}
</div>
</header>
{isWithdrawn && (
<Notice tone="warning">
这篇文档已从公开目录撤下。你正在阅读固定历史版本,已有引用仍可继续使用。
</Notice>
)}
<div className={s.readingLayout}>
<section className={s.panel}>
<Tabs
tabs={[
{ id: "overview", label: "文档概览" },
{ id: "xml", label: "XML 源文档" },
{ id: "versions", label: "版本记录", count: versions.length },
]}
value={tab}
onChange={setTab}
/>
{tab === "overview" ? (
<article className={s.readingArticle}>
<section
id="applies"
className={`${s.proseSection} ${s.appliesCard}`}
>
<h2>
<Target size={16} />
何时使用这篇文档
</h2>
<p>{doc.applies}</p>
</section>
<section id="content" className={s.proseSection}>
<h2>
<FileText size={16} />
指导正文
</h2>
<div className={s.proseBody}>{doc.body}</div>
</section>
{!!doc.steps.length && (
<section id="steps" className={s.proseSection}>
<h2>
<ListChecks size={17} />
建议步骤
</h2>
<ol className={s.stepList}>
{doc.steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ol>
</section>
)}
{!!doc.checks.length && (
<section id="checks" className={s.proseSection}>
<h2>
<CheckSquare size={16} />
完成检查
</h2>
<ul className={s.checkList}>
{doc.checks.map((check, index) => (
<li key={index}>{check}</li>
))}
</ul>
</section>
)}
<section id="related" className={s.proseSection}>
<h2>
<Link2 size={16} />
按需继续阅读
<span className={s.neutralBadge}>{links.length}</span>
</h2>
<p className={s.formDescription}>
只在条件适用、且当前任务需要时,打开下一篇文档。
</p>
{links.map((link, index) => {
const content = (
<>
<div>
<strong>{link.label}</strong>
<p>当{link.when}</p>
<small>
<span>{relationNames[link.relation]}</span>
<span>·</span>
<span>v{link.revision}</span>
{!link.resolved && (
<span>· 固定版本缺失,请跳过</span>
)}
</small>
</div>
{link.resolved && <ArrowUpRight size={15} />}
</>
);
return link.resolved ? (
<Link
className={s.relationCard}
key={index}
href={
(link.target.startsWith("user-")
? "/documents/"
: "/guide/") +
link.target +
"?revision=" +
link.revision
}
>
{content}
</Link>
) : (
<div
className={`${s.relationCard} ${s.unresolved}`}
key={index}
>
{content}
</div>
);
})}
{!links.length && (
<p className={s.proseBody}>
这篇文档可以独立使用,没有需要继续读取的关联文档。
</p>
)}
</section>
</article>
) : tab === "xml" ? (
<>
<div className={s.xmlToolbar}>
<span>{doc.id}.xml</span>
<CopyButton value={xml} label="复制 XML" />
</div>
<pre className={s.codeBlock}>{xml}</pre>
</>
) : (
<div className={s.versionsList}>
{versions.map((version) => (
<div className={s.versionRow} key={version.revision}>
<div>
<div className={s.buttonRow}>
<strong>版本 {version.revision}</strong>
{version.revision === doc.revision && (
<span className={s.neutralBadge}>当前阅读</span>
)}
</div>
<p>{dateLabel(version.createdAt)} · 固定版本</p>
</div>
<Link
className={s.textLink}
href={route + doc.id + "?revision=" + version.revision}
>
读取
<ChevronRight size={13} />
</Link>
</div>
))}
</div>
)}
</section>
<aside className={s.readingSidebar}>
<section className={s.sideCard}>
<h3>文档信息</h3>
<dl className={s.metadataList}>
<div>
<dt>类型</dt>
<dd>{kindNames[doc.kind]}</dd>
</div>
<div>
<dt>来源</dt>
<dd>
{source === "example"
? "啡跃协议样例"
: canEdit
? "你的文档"
: "社区作者"}
</dd>
</div>
<div>
<dt>当前版本</dt>
<dd>
<select
aria-label="选择阅读版本"
value={doc.revision}
onChange={(e) =>
void router.push(
route + doc.id + "?revision=" + e.target.value,
)
}
>
{versions.map((v) => (
<option key={v.revision} value={v.revision}>
v{v.revision}
</option>
))}
</select>
</dd>
</div>
<div>
<dt>可见范围</dt>
<dd>{isWithdrawn ? "仅固定历史版本" : "公开读取"}</dd>
</div>
</dl>
<code className={s.machineAddress}>{doc.id}</code>
{canEdit && !isWithdrawn && (
<div className={s.authorActions}>
<Link
className={s.secondaryButton}
href={"/write?id=" + doc.id}
>
<Pencil size={13} />
编辑文档
</Link>
<button
type="button"
className={s.quietButton}
onClick={() => dialog.current?.showModal()}
>
<Trash2 size={13} />
撤下文档
</button>
</div>
)}
</section>
<section className={`${s.sideCard} ${s.tocCard}`}>
<h3>本篇目录</h3>
<nav className={s.toc} aria-label="本篇目录">
{sectionLinks.map(([id, label]) => (
<a key={id} href={"#" + id} onClick={() => setTab("overview")}>
{label}
</a>
))}
</nav>
</section>
<section className={s.sideCard}>
<h3>
<BookOpen size={15} /> 采用这一篇
</h3>
<p>
把固定版本的读取地址交给 Agent。关联文档是候选帮助,无需一起加载。
</p>
<code className={s.machineAddress}>{api}</code>
<CopyButton
label="复制读取地址"
value={() => window.location.origin + api}
/>
</section>
{!!backlinks.length && (
<section className={s.sideCard}>
<h3>被这些文档引用</h3>
<div className={s.sideLinks}>
{backlinks.map((b) => (
<Link
key={b.id}
href={
(b.source === "community" ? "/documents/" : "/guide/") +
b.id +
(b.revision ? "?revision=" + b.revision : "")
}
>
{b.title}
<ArrowUpRight size={11} />
</Link>
))}
</div>
</section>
)}
</aside>
</div>
<dialog ref={dialog} className={s.modal} aria-labelledby="withdraw-title">
<div className={s.modalBody}>
<h2 id="withdraw-title">撤下这篇文档?</h2>
<p>
它将不再出现在目录中。已发布的固定历史版本会保留,已有引用仍然可以访问。
</p>
{error && <Notice tone="error">{error}</Notice>}
</div>
<div className={s.modalActions}>
<button
type="button"
disabled={busy}
className={s.secondaryButton}
onClick={() => dialog.current?.close()}
>
取消
</button>
<button
type="button"
disabled={busy}
className={s.dangerButton}
onClick={() => void withdraw()}
>
{busy ? "正在撤下…" : "确认撤下"}
</button>
</div>
</dialog>
</GuidanceShell>
);
}
@@ -0,0 +1,277 @@
import a from "@/styles/account.module.css";
import { ThemeToggle } from "./ThemeToggle";
import { Brand } from "./Brand";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
ArrowUpRight,
BookOpen,
Bot,
ChevronRight,
Compass,
Files,
Globe2,
LogIn,
Menu,
Plus,
SquarePen,
UserRound,
Waypoints,
X,
} from "lucide-react";
import s from "@/styles/guidance.module.css";
export type AccountUser = { id: string; email: string; verified_at: string };
type AccountState = {
user: AccountUser | null;
loading: boolean;
error: string;
refresh: () => Promise<void>;
};
const AccountContext = createContext<AccountState>({
user: null,
loading: true,
error: "",
refresh: async () => {},
});
export const useGuidanceAccount = () => useContext(AccountContext);
export default function GuidanceShell({
children,
title = "探索文档",
standalone = false,
}: {
children: ReactNode;
title?: string;
standalone?: boolean;
}) {
const router = useRouter();
const dialog = useRef<HTMLDialogElement>(null);
const [user, setUser] = useState<AccountUser | null>(null),
[loading, setLoading] = useState(true),
[error, setError] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const r = await fetch("/api/account/session");
const b = await r.json();
if (!r.ok) throw Error(b.error || "账户暂时不可用");
setUser(b.user || null);
} catch (e) {
setError(e instanceof Error ? e.message : "账户暂时不可用");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
dialog.current?.close();
}, [router.pathname, router.query.scope]);
const mine = router.query.scope === "mine";
const nav = [
{
href: "/",
label: "探索文档",
Icon: Compass,
active: router.pathname === "/" || router.pathname.startsWith("/guide/"),
},
{
href: "/documents",
label: "社区文档",
Icon: Files,
active: router.pathname.startsWith("/documents") && !mine,
},
{
href: "/documents?scope=mine",
label: "我的文档",
Icon: BookOpen,
active: router.pathname === "/documents" && mine,
},
{
href: "/write",
label: "发布文档",
Icon: SquarePen,
active: router.pathname === "/write",
},
];
function navigation(mobile = false) {
return (
<>
<Link
className={s.brand}
href="/"
onClick={() => dialog.current?.close()}
>
<Brand/>
</Link>
<div className={s.workspaceLabel}>
<Globe2 size={16} />
<span>公共知识空间</span>
<span className={s.workspaceTag}>Public</span>
</div>
<p className={s.navLabel}>工作空间</p>
<nav
aria-label={mobile ? "移动端主导航" : "主导航"}
className={s.navigation}
>
{nav.map(({ href, label, Icon, active }) => (
<Link
href={href}
key={href}
aria-current={active ? "page" : undefined}
className={active ? s.navActive : ""}
onClick={() => dialog.current?.close()}
>
<Icon size={18} />
<span>{label}</span>
</Link>
))}
</nav>
<p className={s.navLabel}>资源</p>
<nav
aria-label={mobile ? "移动端资源" : "资源导航"}
className={s.navigation}
>
<Link
href="/protocol"
aria-current={router.pathname === "/protocol" ? "page" : undefined}
className={router.pathname === "/protocol" ? s.navActive : ""}
onClick={() => dialog.current?.close()}
>
<BookOpen size={18} />
<span>文档协议</span>
</Link>
<a href="/agent/index.md" target="_blank" rel="noreferrer">
<Bot size={18} />
<span>Agent 读取入口</span>
<ArrowUpRight size={14} className={s.navExternal} />
</a>
</nav>
<div className={s.sidebarBottom}>
<div className={s.sidebarHelp}>
<span className={s.smallLabel}>从一篇文档开始</span>
<p>选择适用的指导,需要更多背景时,再沿链接继续阅读。</p>
<Link href="/protocol">
了解使用方法 <ArrowUpRight size={13} />
</Link>
</div>
<Link href="/account" className={s.userArea}>
<span className={s.avatar}>
{user ? (
user.email.slice(0, 1).toUpperCase()
) : (
<UserRound size={18} />
)}
</span>
<span className={s.userInfo}>
<strong>
{user ? user.email : loading ? "正在检查账户…" : "登录你的账户"}
</strong>
<small>
{user ? "管理与发布你的文档" : "邮箱登录 · 开始发布"}
</small>
</span>
<ChevronRight size={15} />
</Link>
</div>
</>
);
}
if (standalone) return (
<AccountContext.Provider value={{ user, loading, error, refresh }}>
<div className={a.page}>
<Head><title>{`${title} · 啡跃`}</title></Head>
<a className={s.skipLink} href="#account-content">跳到正文</a>
<header className={a.header}>
<div><Link href="/">返回首页</Link><ThemeToggle /></div>
</header>
<main id="account-content">{children}</main>
</div>
</AccountContext.Provider>
);
return (
<AccountContext.Provider value={{ user, loading, error, refresh }}>
<div className={s.site}>
<Head>
<title>{`${title} · 啡跃`}</title>
<meta
name="description"
content="为 Agent 编写、发现和连接可独立使用的指导文档。"
/>
</Head>
<a className={s.skipLink} href="#workspace-content">
跳到正文
</a>
<aside className={s.sidebar}>{navigation()}</aside>
<dialog ref={dialog} className={s.mobileDialog} aria-label="工作区导航">
<button
type="button"
className={s.mobileClose}
aria-label="关闭导航"
onClick={() => dialog.current?.close()}
>
<X size={20} />
</button>
{navigation(true)}
</dialog>
<div className={s.workspace}>
<header className={s.topbar}>
<div className={s.breadcrumb}>
<button
type="button"
className={s.mobileMenu}
aria-label="打开导航"
onClick={() => dialog.current?.showModal()}
>
<Menu size={20} />
</button>
<span className={s.breadcrumbSpace}>工作空间</span>
<ChevronRight size={14} />
<strong>{title}</strong>
</div>
<div className={s.topbarActions}><ThemeToggle/>
<Link className={s.topbarProtocol} href="/protocol">
<span className={s.statusDot} />
Linked Guidance 0.1
</Link>
{router.pathname !== "/write" && (
<Link className={s.primaryButton} href="/write">
<Plus size={16} />
创建文档
</Link>
)}
{!user && !loading && (
<Link className={s.topbarLogin} href="/account">
<LogIn size={16} />
<span>登录</span>
</Link>
)}
</div>
</header>
<main className={s.content} id="workspace-content">
{children}
</main>
<footer className={s.workspaceFooter}>
<span>啡跃 · 让知识按需连接</span>
<Link href="/protocol">
文档协议 <ArrowUpRight size={12} />
</Link>
</footer>
</div>
</div>
</AccountContext.Provider>
);
}
@@ -0,0 +1,190 @@
import { useId, useRef, useState, type ReactNode } from "react";
import {
BookOpen,
Check,
CheckCircle2,
Copy,
FileCheck2,
FileText,
Info,
TriangleAlert,
} from "lucide-react";
import type { Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
export const kindNames: Record<Guidance["kind"], string> = {
guide: "操作指导",
context: "背景知识",
evidence: "事实依据",
};
export function KindIcon({
kind,
size = 18,
}: {
kind: Guidance["kind"];
size?: number;
}) {
const Icon =
kind === "guide" ? FileText : kind === "context" ? BookOpen : FileCheck2;
return <Icon size={size} aria-hidden="true" />;
}
export function KindBadge({ kind }: { kind: Guidance["kind"] }) {
return (
<span className={`${s.kindBadge} ${s[kind]}`}>
<KindIcon kind={kind} size={13} />
{kindNames[kind]}
</span>
);
}
export function CopyButton({
value,
label = "复制地址",
className = "",
}: {
value: string | (() => string);
label?: string;
className?: string;
}) {
const [state, setState] = useState<"idle" | "copied" | "error">("idle");
async function copy() {
try {
await navigator.clipboard.writeText(
typeof value === "function" ? value() : value,
);
setState("copied");
} catch {
setState("error");
}
}
return (
<span className={s.copyControl}>
<button
type="button"
className={className || s.secondaryButton}
onClick={() => void copy()}
>
{state === "copied" ? <Check size={15} /> : <Copy size={15} />}
<span>{state === "copied" ? "已复制" : label}</span>
</button>
<span className={s.srOnly} role="status">
{state === "error"
? "无法自动复制,请选择下方地址手动复制。"
: state === "copied"
? "已复制到剪贴板。"
: ""}
</span>
{state === "error" && (
<small className={s.copyError}>请手动复制下方地址</small>
)}
</span>
);
}
export function Notice({
children,
tone = "info",
}: {
children: ReactNode;
tone?: "info" | "success" | "error" | "warning";
}) {
const Icon =
tone === "error" || tone === "warning"
? TriangleAlert
: tone === "success"
? CheckCircle2
: Info;
return (
<div
className={`${s.notice} ${s["notice" + tone]}`}
role={tone === "error" ? "alert" : "status"}
>
<Icon size={18} />
<div>{children}</div>
</div>
);
}
export function PageHeading({
title,
description,
eyebrow,
children,
}: {
title: string;
description?: string;
eyebrow?: string;
children?: ReactNode;
}) {
return (
<header className={s.pageHeading}>
<div>
{eyebrow && <span className={s.eyebrow}>{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
{children && <div className={s.headingActions}>{children}</div>}
</header>
);
}
export function EmptyState({
title,
description,
children,
}: {
title: string;
description: string;
children?: ReactNode;
}) {
return (
<div className={s.emptyState}>
<span className={s.emptyIcon}>
<BookOpen size={24} />
</span>
<h3>{title}</h3>
<p>{description}</p>
{children}
</div>
);
}
export function Tabs({
tabs,
value,
onChange,
}: {
tabs: { id: string; label: string; count?: number }[];
value: string;
onChange: (id: string) => void;
}) {
const group = useId(),
refs = useRef<(HTMLButtonElement | null)[]>([]);
return (
<div role="tablist" aria-label="内容视图" className={s.tabs}>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => {
refs.current[index] = el;
}}
id={`${group}-${tab.id}`}
role="tab"
type="button"
aria-selected={value === tab.id}
tabIndex={value === tab.id ? 0 : -1}
onClick={() => onChange(tab.id)}
onKeyDown={(e) => {
let next = index;
if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
else if (e.key === "ArrowLeft")
next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
else return;
e.preventDefault();
onChange(tabs[next].id);
refs.current[next]?.focus();
}}
>
{tab.label}
{tab.count !== undefined && <span>{tab.count}</span>}
</button>
))}
</div>
);
}
@@ -0,0 +1,25 @@
type Pose = "idea" | "read" | "make" | "collect" | "dream";
/** Original theme-aware line drawings, independent of the fixed brand mark. */
export function CreatorSketch({ pose }: { pose: Pose }) {
return <svg viewBox="0 0 200 220" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13 17 185 14l3 176-174 3ZM17 202q42-3 86 0m-85 8 62 1" strokeWidth="1.5" />
<path d="M42 188q3-46 31-60l42 1q28 16 42 59" fill="var(--selection)" />
<path d="M78 64q-20-3-15-20 5-15 21-11 11-15 29-6 20 7 11 31l-18-10-15 18Z" fill="currentColor" />
<path d="M79 63q-11 5-5 17l8 2q1 27 21 29 21-4 21-39l-3-15m-34 52-5 20m24-18 7 18m-17-58-2 13 8 2m-9 13q9 5 17-3m-23-23h1m24-1h1" />
{pose === "idea" && <><path d="m117 135 17-38 13-48 11 3-7 53-14 49M68 143l-5 27 37 15" fill="var(--background)" /><path d="m141 70-8-31 26-9 11 29Z" fill="var(--selection)" /><path d="m142 42 14-5m-11 14 13-5m-10 13 12-5M151 22V12m16 14 8-7m-41 12-9-4" /></>}
{pose === "read" && <><path d="m49 132 49 16 46-20-4 58-41 15-46-15Z" fill="var(--background)" /><path d="m98 148 1 53m-35-54 23 8m-22 3 20 7m-19 3 16 6m28-16 20-8m-20 19 18-7m-18 17 15-6" /><path d="m50 162 12-5q8 1 5 8l-14 13m89-17-12-4q-9 0-6 7l15 12" fill="var(--background)" /></>}
{pose === "make" && <><path d="m99 143 65-7 15 51-68 5Z" fill="var(--background)" /><path d="m101 150-22 3-8 19 32 5m-39-6 16 7 29-2m21-12h13m-28 29H37" /><path d="m151 46 5-14 8 12 15 2-11 9 1 15-13-8-13 5 4-13-10-10Z" fill="var(--selection)" /></>}
{pose === "collect" && <><path d="m60 156 15-18 53 7-5 40-62-5Z" fill="var(--background)" /><path d="m76 138 8-11 19 3 7 13m-12 9a12 12 0 1 0 1 24 12 12 0 0 0-1-24m-36 10-11-5-8 8 15 15m64-21 15 9-13 11" /><path d="m143 68 25-7 7 31-25 7Zm7 14 6-6 10 9m-8-14h1" stroke="var(--primary)" /></>}
{pose === "dream" && <><path d="m82 136 16 30 26-6 12-36m-18 38 18 11 11-35-7-20q-4-7-8-3l-2 14-6-7m-46 26-9 25 28 14 37-13" fill="var(--background)" /><path d="m142 32 29-10-13 25-4-12-12-3 12 3 17-13m-36 25q-21 4-15 17t25 2" stroke="var(--primary)" /></>}
</svg>;
}
export function IdeaSketch({ kind }: { kind: "collect" | "connect" | "read" | "make" }) {
return <svg viewBox="0 0 140 110" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{kind === "collect" && <><path d="m43 25 49-5 7 64-53 6Z" fill="var(--selection)" /><path d="m54 39 25-2m-24 14 30-3m-28 14 21-2m20-23 18-20 7 7-20 21-9 3ZM34 18l-6-8m19 3-1-10m61 62 8 3m-81 2-10 2" /></>}
{kind === "connect" && <><path d="M42 37q27-31 50-1m-48 8q27-17 47-1M45 62q23 28 46 2" /><path d="m18 32 29-3 3 34-31 2Zm72-8 32 4-3 33-30-3ZM52 73l32-1 2 25-32 2Z" fill="var(--selection)" /><path d="m26 43 14-1m-13 9 10-1m62-13 13 2m-13 7 11 1M62 83h13m-12 7h9" /></>}
{kind === "read" && <><path d="M20 30q25-8 49 7 24-17 51-10l-3 56q-24-5-48 10-22-13-47-7Z" fill="var(--selection)" /><path d="m69 37 0 56M32 42q13-1 25 5m-23 8 21 5m-21 8 20 4m27-27 24-6m-23 19 21-6m-20 18 18-4m-35-44-2-11m17 10 6-10" /></>}
{kind === "make" && <><path d="m24 29 85-3 5 63-86 4ZM31 41h71M45 34h1m-10 0h1m20-1h1m-18 21 21-2 1 26-22 1Zm31 0 27-1m-26 12 20-1m-19 13 24-1" /><path d="m103 14 7-11 4 12 13 2-10 8 2 11-10-6-10 7 2-12-10-7Z" fill="var(--selection)" /></>}
</svg>;
}
@@ -0,0 +1,20 @@
import {useEffect,useState,type FormEvent} from 'react';
import type {ExploreEntry} from '@/lib/explore';
import type {Tag} from '@/lib/model';
import {TagInput} from './TagInput';
import {NoteMarkdown} from './NoteMarkdown';
export function NoteEditor({entry,onSaved,onCancel}:{entry:ExploreEntry;onSaved:(entry:ExploreEntry)=>void;onCancel:()=>void}){
const [title,setTitle]=useState(entry.title),[description,setDescription]=useState(entry.description),[markdown,setMarkdown]=useState(entry.editableMarkdown||'');
const [allTags,setAllTags]=useState<Tag[]>([]),[selected,setSelected]=useState<string[]>([]),[revision,setRevision]=useState('');
const [loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[tagBusy,setTagBusy]=useState(false),[error,setError]=useState('');
useEffect(()=>{let live=true;void(async()=>{try{const res=await fetch('/api/notes/'+entry.id);const data=await res.json();if(!res.ok)throw Error(data.error||'无法读取笔记');if(live){setTitle(data.entry.title);setDescription(data.entry.description);setMarkdown(data.entry.editableMarkdown);setRevision(data.entry.revision);setAllTags(data.tags);setSelected(data.tags.filter((t:Tag)=>[...data.entry.categories,...(data.entry.systemTags||[]).map((s:Tag)=>s.name)].some((name:string)=>name.toLocaleLowerCase()===t.name.toLocaleLowerCase())).map((t:Tag)=>t.id));}}catch(e){if(live)setError(e instanceof Error?e.message:'读取失败');}finally{if(live)setLoading(false)}})();return()=>{live=false}},[entry.id]);
async function save(e:FormEvent){e.preventDefault();if(busy||loading||tagBusy||!revision)return;setBusy(true);setError('');try{const res=await fetch('/api/notes/'+entry.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,description,markdown,tags:selected,revision})});const data=await res.json();if(!res.ok)throw Error(data.error||'保存失败');onSaved(data.entry);}catch(e){setError(e instanceof Error?e.message:'保存失败');}finally{setBusy(false)}}
return <form className="note-editor" onSubmit={save} aria-label="编辑笔记"><h1 id="note-title">编辑笔记</h1>{loading&&<p role="status">正在读取最新内容…</p>}
<fieldset disabled={loading||busy}><div className="form-field"><label htmlFor="note-edit-title">标题</label><input id="note-edit-title" autoFocus required maxLength={200} value={title} onChange={e=>setTitle(e.target.value)}/></div>
<div className="form-field"><label htmlFor="note-edit-description">描述</label><textarea id="note-edit-description" rows={3} maxLength={1000} value={description} onChange={e=>setDescription(e.target.value)}/></div>
{!loading&&<TagInput initial={allTags} selected={selected} onChange={setSelected} disabled={busy} onBusyChange={setTagBusy}/>}
<div className="form-field"><label htmlFor="note-edit-body">正文 <small>支持 Markdown 列表、链接和代码块</small></label><textarea id="note-edit-body" className="note-editor-body" rows={15} maxLength={100000} value={markdown} onChange={e=>setMarkdown(e.target.value)}/></div>
<details><summary>预览正文</summary><NoteMarkdown text={markdown}/></details></fieldset>
{error&&<p role="alert" className="error-message">{error}</p>}
<div className="note-editor-actions"><button type="button" className="secondary-button" disabled={busy||tagBusy} onClick={onCancel}>取消</button><button type="submit" className="primary-button" disabled={loading||busy||tagBusy||!revision}>{busy?'保存中…':'保存修改'}</button></div></form>;
}
@@ -0,0 +1,14 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {Link as LinkIcon,ArrowUpRight} from 'lucide-react';
export function NoteMarkdown({text}:{text:string}){
return <div className="note-markdown"><ReactMarkdown remarkPlugins={[remarkGfm]} components={{
a:({href,children})=>{
if(!href)return <span>{children}</span>;
const destination=href.replace(/\?agent_mode=True$/i,'');
return <a className="note-inline-link" href={destination}><LinkIcon size={13} aria-hidden="true"/><span>{children}</span>{/^https?:/.test(destination)&&<ArrowUpRight size={12} aria-hidden="true"/>}</a>;
},
img:({src,alt})=>typeof src==='string'?<a className="note-inline-link" href={src}><LinkIcon size={13} aria-hidden="true"/>{alt||'查看图片'}</a>:null,
}}>{text}</ReactMarkdown></div>;
}
@@ -0,0 +1,11 @@
import {useState} from 'react';
import {Tags,X} from 'lucide-react';
import type {ExploreEntry} from '@/lib/explore';
import type {Tag} from '@/lib/model';
import {TagInput} from './TagInput';
export function QuickTags({entry,onSaved}:{entry:ExploreEntry;onSaved:(value:ExploreEntry)=>void}){
const [open,setOpen]=useState(false),[tags,setTags]=useState<Tag[]>([]),[selected,setSelected]=useState<string[]>([]),[revision,setRevision]=useState(''),[loading,setLoading]=useState(false),[busy,setBusy]=useState(false),[tagBusy,setTagBusy]=useState(false),[error,setError]=useState('');
async function show(){setOpen(true);setLoading(true);setError('');setRevision('');try{const r=await fetch('/api/notes/'+entry.id),d=await r.json();if(!r.ok)throw Error(d.error);setTags(d.tags);const names=[...d.entry.categories,...(d.entry.systemTags||[]).map((t:Tag)=>t.name)];setSelected(d.tags.filter((t:Tag)=>names.includes(t.name)).map((t:Tag)=>t.id));setRevision(d.entry.revision);}catch{setError('读取标签失败,请关闭后重试')}finally{setLoading(false)}}
async function save(){setBusy(true);setError('');try{const r=await fetch('/api/notes/'+entry.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({tags:selected,revision})}),d=await r.json();if(!r.ok)throw Error(d.error);onSaved(d.entry);setOpen(false);}catch(e){setError(e instanceof Error?e.message:'保存失败')}finally{setBusy(false)}}
return <><button type="button" className="quick-tags-trigger" aria-label={'编辑 '+entry.title+' 的标签'} onClick={()=>void show()}><Tags size={14}/></button>{open&&<div className="tag-popover" role="dialog" aria-label={'编辑 '+entry.title+' 的标签'}><header><b>编辑标签</b><button type="button" aria-label="关闭标签编辑" disabled={busy||tagBusy} onClick={()=>setOpen(false)}><X size={17}/></button></header>{loading?<p role="status">读取中…</p>:revision&&<TagInput initial={tags} selected={selected} onChange={setSelected} disabled={busy} onBusyChange={setTagBusy}/>}<p role="alert" className="error-message">{error}</p><button type="button" className="primary-button" disabled={loading||busy||tagBusy||!revision} onClick={()=>void save()}>{busy?'保存中…':'保存标签'}</button></div>}</>;
}
+17
View File
@@ -0,0 +1,17 @@
import { ThemeToggle } from "./ThemeToggle";
import { Brand } from "./Brand";
import Link from "next/link";
import { useRouter } from "next/router";
import { Grid2X2,Image,Film,AudioLines,BookOpen,Clapperboard,Sparkles,ArrowUpRight,Layers,Tags,Upload,Menu,X,Search } from "lucide-react";
import { useEffect,useState,type ReactNode } from "react";
const groups=[{label:'',items:[{href:'/',label:'首页',icon:Grid2X2},{href:'/categories',label:'分类',icon:Tags}]}];
export function Shell({children}:{children:ReactNode}){
const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||''));
useEffect(()=>setQuery(String(router.query.q||'')),[router.query.q]);
return <div className="app-shell"><button className="mobile-menu icon-button" aria-label={open?"关闭导航":"展开导航"} aria-expanded={open} onClick={()=>setOpen(!open)}>{open?<X size={22}/>:<Menu size={22}/>}</button>
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="啡跃 Endoleap 首页" onClick={()=>setOpen(false)}><Brand/></Link>
<nav>{groups.map((g,i)=><div className="nav-group" key={i}>{g.label&&<p>{g.label}</p>}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return <Link key={item.href} href={item.href} title={item.label} className={`nav-item ${active?'active':''}`} onClick={()=>setOpen(false)}><item.icon size={24} strokeWidth={active?2.2:1.8}/><span>{item.label}</span></Link>;})}</div>)}</nav>
<button className="auth-logout" onClick={async()=>{const response=await fetch('/api/auth/logout',{method:'POST'});if(response.ok)window.location.assign('/login');}}>退出登录</button></aside><main className="main-content"><div className="global-topbar"><ThemeToggle/><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
</div>;
}
@@ -0,0 +1,6 @@
import {guidanceUrl} from '@/content/page-links';
import {previewUrl} from '@/content/page-links';
import Link from 'next/link';
import {ArrowUpRight} from 'lucide-react';
import {skillCatalog} from '@/content/skills/catalog';
export function SkillCard({skill}:{skill:typeof skillCatalog[number]}){return <Link href={guidanceUrl(skill.id)} className="post-card skill-pin"><div className="post-cover skill-pin-cover"><img src={previewUrl(skill.image,480)} alt={skill.alt} loading="lazy"/></div><h3 title={skill.title}>{skill.title}</h3><div className="post-meta"><span>{skill.category}</span><ArrowUpRight size={14} aria-hidden="true"/></div></Link>}
@@ -0,0 +1,49 @@
import {previewUrl} from '@/content/page-links';
import Head from 'next/head';
import Link from 'next/link';
import {useState,useEffect,useRef} from 'react';
import {X,PanelRight,Copy,ExternalLink} from 'lucide-react';
import {Shell} from './Shell';
import {getGuidance,guidanceMarkdown,guidanceBacklinks,humanDocumentUrl,markdownDocumentUrl,legacyDocumentPath,type GuidanceResource} from '@/content/skills/guidance';
export function SkillDocs({documentPath,documentId='index',skillId='street-people'}:{documentPath?:string;documentId?:string;skillId?:string}){
const path=documentPath??legacyDocumentPath(skillId,documentId);
const doc=getGuidance(path)!;
const [open,setOpen]=useState(false);
const [selected,setSelected]=useState<GuidanceResource|null>(null);
const [copied,setCopied]=useState(false);
const panel=useRef<HTMLElement>(null);
useEffect(()=>{setOpen(window.innerWidth>760)},[]);
useEffect(()=>{setSelected(null);setCopied(false)},[path]);
useEffect(()=>{if(!open)return;const handler=(e:KeyboardEvent)=>{if(e.key==='Escape')setOpen(false)};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler)},[open]);
const resource=(id:string)=>doc.resources.find(r=>r.id===id)!;
const incoming=guidanceBacklinks(path);
async function copy(){
try{
const value=new URL(markdownDocumentUrl(path),location.origin).href;
if(navigator.clipboard)await navigator.clipboard.writeText(value);
else{const input=document.createElement('textarea');input.value=value;document.body.appendChild(input);input.select();const ok=document.execCommand('copy');input.remove();if(!ok)throw Error('Copy unavailable');}
setCopied(true);
}catch{setCopied(false)}
}
return <Shell><Head><title>{doc.title} · 指导文档 · 啡跃 Endoleap</title><link rel="canonical" href={humanDocumentUrl(path)}/><link rel="alternate" type="text/markdown" href={markdownDocumentUrl(path)} title={doc.title}/></Head>
<div className="docs-top"><Link href="/skills">← 全部指导文档</Link><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}><PanelRight size={16}/>文档与来源 · {doc.resources.length}</button></div>
<div className={`docs-layout ${open?'with-sources':''}`}><article className="docs-body">
<div className="docs-eyebrow">{doc.collection} / 指导文档</div><h1>{doc.title}</h1>
<div className="docs-actions"><button onClick={copy}><Copy size={14}/>{copied?'已复制本文 Markdown 地址':'复制本文给 Agent'}</button><a href={markdownDocumentUrl(path)} target="_blank" rel="noreferrer">本文 Markdown ↗</a></div>
<p className="docs-hint">网页与 Markdown 来自同一份文档。每篇都有独立地址,可以直接进入,也可以沿引用继续阅读。</p>
{doc.blocks.map((b,i)=>b.type==='heading'?<h2 key={i}>{b.text}</h2>:b.type==='code'?<pre key={i}>{b.text}</pre>:b.type==='links'?<div className="docs-inline-links" key={i}>{b.refs!.map(id=>{
const r=resource(id);
return r.kind==='document'?<Link href={r.human!} key={id}>文档 · {r.title}<ExternalLink size={12}/></Link>:<a href={r.href} key={id} onClick={e=>{e.preventDefault();setSelected(r);setOpen(true);panel.current?.focus()}}>{r.kind==='image'?'图片':r.method} · {r.title}<ExternalLink size={12}/></a>;
})}</div>:<p key={i}>{b.text}</p>)}
{incoming.length>0&&<><h2>引用本文的文档</h2><div className="docs-inline-links">{incoming.map(d=><Link key={d.path} href={humanDocumentUrl(d.path)}>{d.title}<ExternalLink size={12}/></Link>)}</div></>}
</article>
{open&&<aside className="docs-sources" ref={panel} tabIndex={-1} aria-label="文档与资源"><header><div><b>文档与来源</b><small>本文的文档、参考图与接口</small></div><button aria-label="关闭文档与来源" onClick={()=>setOpen(false)}><X size={18}/></button></header>
{(['document','image','api'] as const).map(kind=><section key={kind}><h3>{kind==='document'?'指导文档':kind==='image'?'参考与示意图':'API 接口'}</h3>{doc.resources.filter(r=>r.kind===kind).map(r=><button key={r.id} className={selected?.id===r.id?'selected':''} onClick={()=>setSelected(r)}><span>{r.method||(r.kind==='image'?'IMG':'MD')}</span><div>{r.title}<small>{r.human||r.href}</small></div></button>)}</section>)}
{selected&&<div className="docs-resource-preview"><h3>{selected.title}</h3>{selected.kind==='image'?<img src={previewUrl(selected.href,1280)} alt={selected.title}/>:selected.kind==='document'?<pre>{guidanceMarkdown(selected.documentPath!)}</pre>:<><code>{selected.method} {selected.href}</code><pre>{JSON.stringify(selected.detail||{method:selected.method,path:selected.href},null,2)}</pre><p>{selected.description}</p></>}
<div className="docs-resource-actions">{selected.human&&<Link href={selected.human}>打开文档网页 ↗</Link>}{selected.method!=='POST'&&!selected.template&&<a href={selected.href} target="_blank" rel="noreferrer">{selected.kind==='document'?'打开 Markdown':'打开资源'} ↗</a>}</div>
</div>}
</aside>}
</div>
</Shell>;
}
@@ -0,0 +1,11 @@
import {useId,useState} from 'react';
import type {Tag} from '@/lib/model';
export function TagInput({initial,selected,onChange,disabled=false,onBusyChange}:{initial:Tag[];selected:string[];onChange:(ids:string[])=>void;disabled?:boolean;onBusyChange?:(busy:boolean)=>void}){
const uid=useId(),[all,setAll]=useState(initial),[input,setInput]=useState(''),[systemQuery,setSystemQuery]=useState(''),[busy,setBusy]=useState(false),[error,setError]=useState('');
const name=input.replace(/^#/,'').trim(),isHash=input.startsWith('#'),ordinary=all.filter(t=>t.kind!=='system'),systems=all.filter(t=>t.kind==='system');
const suggestions=ordinary.filter(t=>!selected.includes(t.id)&&(!name||t.name.toLocaleLowerCase().includes(name.toLocaleLowerCase()))).slice(0,8);
function toggle(id:string){onChange(selected.includes(id)?selected.filter(v=>v!==id):[...selected,id]);}
async function add(){if(!isHash||!name||busy||disabled||selected.length>=50)return;setBusy(true);onBusyChange?.(true);setError('');try{const res=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:input})});const tag:Tag&{error?:string}=await res.json();if(!res.ok)throw Error(tag.error||'标签创建失败');if(tag.kind==='system')throw Error('请在系统标签列表勾选');setAll(a=>a.some(t=>t.id===tag.id)?a:[...a,tag]);if(!selected.includes(tag.id))onChange([...selected,tag.id]);setInput('');}catch(e){setError(e instanceof Error?e.message:'标签创建失败');}finally{setBusy(false);onBusyChange?.(false);}}
return <div className="tag-manager"><div className="form-field"><label htmlFor={uid+'-normal'}>普通标签 <small>输入 #标签,回车添加或新建</small></label><div className="tag-input-selected">{ordinary.filter(t=>selected.includes(t.id)).map(t=><button key={t.id} type="button" disabled={disabled||busy} aria-label={'移除标签 '+t.name} onClick={()=>toggle(t.id)}>#{t.name}<span aria-hidden="true">×</span></button>)}</div><div className="tag-input-row"><input id={uid+'-normal'} value={input} maxLength={61} disabled={disabled||busy} placeholder="#添加普通标签" onChange={e=>setInput(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!e.nativeEvent.isComposing){e.preventDefault();void add()}}}/><button type="button" className="secondary-button" disabled={!isHash||!name||busy||disabled||selected.length>=50} onClick={()=>void add()}>{busy?'添加中…':'添加'}</button></div><div className="tag-input-suggestions">{suggestions.map(t=><button key={t.id} type="button" disabled={disabled||busy||selected.length>=50} onClick={()=>{toggle(t.id);setInput('')}}>#{t.name}</button>)}</div></div>
<fieldset className="system-tag-picker" disabled={disabled||busy}><legend>系统标签 <small>只能搜索和勾选已有标签</small></legend><input aria-label="搜索系统标签" placeholder="搜索系统标签" value={systemQuery} onKeyDown={e=>{if(e.key==='Enter')e.preventDefault()}} onChange={e=>setSystemQuery(e.target.value)}/><div>{systems.filter(t=>t.name.includes(systemQuery.trim())).map(t=><label key={t.id}><input type="checkbox" checked={selected.includes(t.id)} disabled={!selected.includes(t.id)&&selected.length>=50} onChange={()=>toggle(t.id)}/>{t.name}</label>)}</div>{!systems.some(t=>t.name.includes(systemQuery.trim()))&&<p>没有匹配的系统标签</p>}</fieldset>{error&&<p role="alert" className="error-message">{error}</p>}</div>;
}
@@ -0,0 +1,7 @@
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [dark, setDark] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}><span aria-hidden="true">◐</span></button>;
}
@@ -0,0 +1,366 @@
import Head from 'next/head';
import {useEffect, useRef, useState, type ReactNode} from 'react';
import {ArrowLeft, ArrowUpRight, BookOpen, Bot, Check, ChevronDown, ChevronRight, Layers, LogOut, User, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
import {Brand} from './Brand';
import {ThemeToggle} from './ThemeToggle';
import {parseCanvasDocument, type CanvasNode} from '@/lib/canvas-document';
import s from '@/styles/workspace.module.css';
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 = {id?: string; name: string; purpose: string; owner?: boolean};
const spaceKey = (space: Space) => space.id ?? space.name;
export type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string};
const personal = {name: '个人空间', purpose: ''};
type WorkspaceShellProps = {
editor?: (subset: Subcanvas | undefined) => ReactNode;
liveNodes?: CanvasNode[];
};
export function WorkspaceShell({editor, liveNodes}: WorkspaceShellProps) {
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<'choice' | 'join' | 'purpose' | 'name' | 'invite'>('choice');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [purpose, setPurpose] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [savedNodes, setNodes] = useState<CanvasNode[]>([]);
const nodes = liveNodes ?? savedNodes;
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
const [selectedCanvas, setSelectedCanvas] = useState(editor ? '' : 'history');
const [draftEditedAt, setDraftEditedAt] = useState<string>();
const [expanded, setExpanded] = useState(true);
const [subsetName, setSubsetName] = useState('');
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
const subsetDialog = useRef<HTMLDialogElement>(null);
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) {
if (editor) setState('ready');
else window.location.replace('/account?next=/workspace');
return;
}
setEmail(data.user.email);
let local: Space[] = [];
let activeKey = '';
try {
const saved = JSON.parse(localStorage.getItem('studio.workspace:' + data.user.email) || 'null');
if (Array.isArray(saved?.spaces)) local = saved.spaces.filter((space: Space) => !space.id);
activeKey = saved?.active || '';
} catch {}
let shared: Space[] = [];
try {
const response = await fetch('/api/account/workspaces', {signal: request.signal});
if (response.ok) shared = (await response.json()).spaces;
} catch {}
if (request.signal.aborted) return;
const available = [...local, ...shared];
setSpaces(available);
setActive(available.find(space => spaceKey(space) === activeKey) || personal);
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: spaceKey(space)}));
setSpaces(next);
setActive(space);
switcher.current?.removeAttribute('open');
}
const subsetKey = 'studio.subcanvases:' + email + ':' + spaceKey(active);
useEffect(() => {
if (!email) return;
setSelectedCanvas(editor ? '' : '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([]);
}
try {
const saved = JSON.parse(localStorage.getItem(subsetKey) || '[]');
const list: Subcanvas[] = Array.isArray(saved) ? saved : [];
setSubcanvases(list);
if (editor) {
const id = new URLSearchParams(window.location.search).get('subset');
if (list.some(item => item.id === id)) setSelectedCanvas(id!);
}
} catch {
setSubcanvases([]);
}
}, [email, subsetKey]);
function openSubsetDialog(edit?: Subcanvas) {
setError('');
setSubsetName(edit?.name ?? '');
setSubsetNodes(edit?.nodes ?? []);
subsetDialog.current?.showModal();
}
function saveSubset(event: React.FormEvent) {
event.preventDefault();
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, editedAt: new Date().toISOString()};
const next = [...subcanvases.filter(item => item.id !== id), subset];
try {
localStorage.setItem(subsetKey, JSON.stringify(next));
setSubcanvases(next);
setSelectedCanvas(id);
setExpanded(true);
subsetDialog.current?.close();
} catch {
setError('浏览器无法保存子画布。');
}
}
async function signOut() {
try {
const response = await fetch('/api/account/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
if (!response.ok) throw Error();
window.location.assign('/account?next=/workspace');
} catch {
setNotice('暂时无法退出登录,请重试。');
}
}
function openDialog() {
setStep('choice');
setCode('');
setPurpose('');
setName('');
setError('');
dialog.current?.showModal();
}
async function workspaceRequest(payload: object) {
const response = await fetch('/api/account/workspaces', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
const result = await response.json();
if (!response.ok) throw new Error(result.error || '空间操作失败');
return result;
}
async function submitWorkspace(event: React.FormEvent) {
event.preventDefault();
if (busy) return;
const clean = name.trim();
if (step === 'name' && !clean) return setError('请填写 Workspace 名称。');
if (step === 'join' && !code.trim()) return setError('请输入邀请码。');
setBusy(true);
setError('');
try {
const {space} = await workspaceRequest(step === 'join' ? {action:'join', code:code.trim()} : {action:'create', name:clean, purpose});
selectSpace(space, [...spaces.filter(item => spaceKey(item) !== spaceKey(space)), space]);
dialog.current?.close();
setNotice((step === 'join' ? '已加入' : '已创建') + '「' + space.name + '」');
} catch (error) {
setError(error instanceof Error ? error.message : '空间操作失败');
} finally {
setBusy(false);
}
}
async function inviteMember() {
if (busy || !active.id) return;
switcher.current?.removeAttribute('open');
setCode('');
setError('');
setStep('invite');
dialog.current?.showModal();
setBusy(true);
try {
const result = await workspaceRequest({action:'invite', workspaceId:active.id});
setCode(result.code);
} catch (error) {
setError(error instanceof Error ? error.message : '无法生成邀请码');
} finally {
setBusy(false);
}
}
const subset = subcanvases.find(item => item.id === selectedCanvas);
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}>
{!editor && <Head><title>工作台 · 啡跃</title></Head>}
<nav className={s.rail} aria-label="全局导航">
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand markOnly/></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}>
<div className={s.menuIdentity}><span className={s.avatar}>{Array.from(active.name)[0]}</span><div><strong>{active.name}</strong><small>{active.id ? '当前空间 · 共享空间' : '当前空间 · 保存在此浏览器'}</small></div></div>
{email && <div className={s.menuSection}>
{active.owner && <button type="button" onClick={() => void inviteMember()}><Plus size={16}/>邀请成员</button>}<small>{email}</small>
{[personal, ...spaces].map(space => <button type="button" key={spaceKey(space)} aria-pressed={spaceKey(space) === spaceKey(active)} onClick={() => {
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
}}><span className={s.avatar}>{Array.from(space.name)[0]}</span><span>{space.name}</span>{spaceKey(space) === spaceKey(active) && <Check size={16}/>}</button>)}
<button className={s.menuAdd} type="button" onClick={() => {switcher.current?.removeAttribute('open'); openDialog();}}><Plus size={16}/>添加 Workspace</button>
</div>}
<div className={s.menuSection}><a href="/account"><User size={16}/>账户管理</a>{email && <button type="button" onClick={() => void signOut()}><LogOut size={16}/>退出登录</button>}</div>
</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><button type="button" disabled={!email || !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={() => editor ? window.location.assign('/workspace') : 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 && !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>}
</nav>}
<small className={s.local}>空间设置保存在此浏览器</small>
</aside>}
<main className={editor ? s.editorMain : s.main}>
{editor ? <>
<button className={s.editorToggle} type="button" aria-label="切换画布树" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button>
{editor(subset)}
</> : <>
<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} / {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> :
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>
<p>{subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}</p>
{subset ? <><button className={s.primary} type="button" onClick={() => openSubsetDialog(subset)}>编辑子画布</button><a className={s.openSubset} href={'/infinite_canvas?subset=' + encodeURIComponent(subset.id)}>在编辑器打开 <ArrowUpRight size={16}/></a></> : <a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a>}
</section>
<section aria-labelledby="workspace-nodes">
<h2 id="workspace-nodes">画布内容</h2>
{visibleNodes.length ? <div className={s.tools}>{visibleNodes.map(node => <article className={s.node} key={node.id}><strong>{node.title || '未命名节点'}</strong>{node.image && <img src={node.image} alt=""/>}{node.text && <p>{node.text}</p>}</article>)}</div> : <p>{subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}</p>}
</section>
<nav className={s.shortcuts} aria-label="其他创作工具">{tools.slice(1).map(({label, href}) => <a key={href} href={href}>{label} <ArrowUpRight size={14}/></a>)}</nav>
</div>}
</>}
</main>
<dialog className={s.dialog} ref={subsetDialog} aria-labelledby="subcanvas-title">
<div className={s.dialogTop}><h2 id="subcanvas-title">{subset ? '编辑子画布' : '新建子画布'}</h2><button type="button" aria-label="关闭" onClick={() => subsetDialog.current?.close()}><X size={20}/></button></div>
<form onSubmit={saveSubset}>
<p>从无限画布选择节点。原画布会保留这些节点。</p>
<label htmlFor="subcanvas-name">子画布名称</label>
<input id="subcanvas-name" value={subsetName} maxLength={40} onChange={event => setSubsetName(event.target.value)}/>
<fieldset className={s.members}><legend>包含的节点</legend>{nodes.map(node => <label key={node.id}><input type="checkbox" checked={subsetNodes.includes(node.id)} onChange={event => setSubsetNodes(event.target.checked ? [...subsetNodes, node.id] : subsetNodes.filter(id => id !== node.id))}/>{node.title || '未命名节点'}</label>)}</fieldset>
{error && <p className={s.error} role="alert">{error}</p>}
<button className={s.primary} type="submit">保存子画布</button>
</form>
</dialog>
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
<div className={s.dialogTop}>
{step !== 'choice' && step !== 'invite' && <button type="button" aria-label="返回上一步" onClick={() => {setError(''); setStep(step === 'name' ? 'purpose' : 'choice');}}><ArrowLeft size={20}/></button>}
<h2 id="workspace-dialog-title">{{choice:'建立一个创作空间',join:'加入 Workspace',purpose:'这个空间用来做什么?',name:'给你的 Workspace 命名',invite:'邀请成员'}[step]}</h2>
<button type="button" aria-label="关闭" onClick={() => dialog.current?.close()}><X size={20}/></button>
</div>
{step === 'choice' ? <>
<p>加入已有空间,或创建自己的 Workspace。</p>
<div className={s.options}>
<button type="button" onClick={() => setStep('join')}><User size={22}/><span><strong>加入已有空间</strong><small>使用空间所有者提供的邀请码</small></span></button>
<button type="button" onClick={() => setStep('purpose')}><Plus size={22}/><span><strong>创建 Workspace</strong><small>为新项目建立一个空间</small></span></button>
</div>
</> : step === 'join' ? <form onSubmit={submitWorkspace}>
<label htmlFor="workspace-invite">邀请码</label>
<input id="workspace-invite" value={code} maxLength={80} onChange={event => setCode(event.target.value)}/>
{error && <p className={s.error} role="alert">{error}</p>}
<button className={s.primary} type="submit" disabled={busy}>加入 Workspace</button>
</form> : step === 'invite' ? <>
<p>邀请码有效期为 24 小时。把它交给要加入的成员。</p>
{busy && <p role="status">正在生成邀请码…</p>}
{error && <p className={s.error} role="alert">{error}</p>}
{code && <input aria-label="邀请码" readOnly value={code} onFocus={event => event.target.select()}/>}
</> : 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={submitWorkspace}>
<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" disabled={busy}>创建 Workspace <ArrowUpRight size={16}/></button>
</form>}
</dialog>
{notice && <div role="status" className={s.notice}>{notice}</div>}
</div>;
}