Files
probably-rendering-one-more…/apps/dashboard/src/components/DocumentDirectory.tsx
T

496 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
)}
</>
);
}