Rename the unified application to dashboard
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/** The approved mark is copied from @endoleap/brand during the remote build. */
|
||||
export function Brand({ compact = false }: { compact?: 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}/><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>}</>;
|
||||
}
|
||||
@@ -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>}</>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user