Files
endorphin-tabularium-docs/apps/docs/src/components/Shell.tsx
T
codex-bot 627c0ee887
Verify and deploy Tabularium / verify-and-deploy (pull_request) Has been cancelled
Refine documentation UI with distinct collection navigation and borderless surfaces
2026-10-28 15:31:25 +08:00

261 lines
9.1 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 { Icon } from "@/components/Icon";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
export function Brand() {
return (
<Link className="brand" href="/" aria-label="Tabularium 首页">
<span className="brand-mark" aria-hidden="true">
<i />
<i />
<i />
</span>
<span>
<strong>Tabularium</strong>
</span>
<span className="docs-badge">Docs</span>
</Link>
);
}
export function Navigation({
docs,
current,
onNavigate,
}: {
docs: DocLink[];
current?: string;
onNavigate?: () => void;
}) {
const collection = docs.find((d) => d.slug === current)?.collection;
const scopedDocs = docs.filter((d) => d.collection === collection);
const groups = [...new Set(scopedDocs.map((d) => d.group))];
const collections = collectionEntries(docs);
return (
<nav aria-label="文档导航">
{collection ? <>
<Link className="nav-back" href="/" onClick={onNavigate}>
<Icon name="arrow" size={15} style={{ transform: "rotate(180deg)" }} />全部文档
</Link>
<Link className="collection-identity" href={`/docs/${collections.find((c) => c.name === collection)!.entry.slug}`} onClick={onNavigate}>
<Icon name={collectionIcon(collection)} size={22} /><span>{collectionLabel(collection)}</span>
</Link>
</> : <div className="collection-identity"><Icon name="book" size={22} /><span>文档中心</span></div>}
{!current && collections.map(({ name, entry }) => <Link
onClick={onNavigate} className="collection-nav-link" href={`/docs/${entry.slug}`} key={name}
><Icon name={collectionIcon(name)} size={18} /><span>{collectionLabel(name)}</span><Icon name="chevron" size={13} /></Link>)}
{groups.map((group) => (
<div className="nav-group" key={group}>
<p>{group === collection ? "章节目录" : group}</p>
{scopedDocs.filter((d) => d.group === group).map((d) => (
<Link onClick={onNavigate} aria-current={d.slug === current ? "page" : undefined}
className="nav-item" href={`/docs/${d.slug}`} key={d.slug}>
<span>{d.slug.endsWith("/00-overview") ? "概览" : d.title.replace("(草案)", "")}</span>
{d.draft && <span className="tiny-dot" aria-label="草案" />}
</Link>
))}
</div>
))}
</nav>
);
}
export function Shell({
docs,
current,
children,
}: {
docs: DocLink[];
current?: string;
children: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const input = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState("");
const [searchDocs, setSearchDocs] = useState(docs);
const [searchLoaded, setSearchLoaded] = useState(false);
const [searchStatus, setSearchStatus] = useState("");
const [selected, setSelected] = useState(0);
const [menu, setMenu] = useState(false);
const collection = docs.find((d) => d.slug === current)?.collection;
const collections = collectionEntries(docs);
const results = searchDocs.filter((d) =>
(!collection || d.collection === collection) &&
`${d.title} ${d.description} ${d.searchText ?? ""}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
);
async function openSearch() {
setQuery("");
setSelected(0);
dialog.current?.showModal();
input.current?.focus();
if (searchLoaded) return;
setSearchStatus("正在加载全文索引…");
try {
const response = await fetch("/api/search");
if (!response.ok) throw new Error("Search unavailable");
setSearchDocs(await response.json());
setSearchLoaded(true);
setSearchStatus("");
} catch {
setSearchStatus("全文搜索暂时不可用,当前仅搜索标题和摘要。关闭后重新打开可重试。");
}
}
function closeSearch() {
dialog.current?.close();
trigger.current?.focus();
}
useEffect(() => {
function keyboard(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
trigger.current?.click();
}
}
document.addEventListener("keydown", keyboard);
return () => document.removeEventListener("keydown", keyboard);
}, []);
return (
<div className="docs-shell" data-tone={collectionTone(collection)}>
<a className="skip-link" href="#content">
跳到正文
</a>
<header className="site-header">
<Brand />
<nav className="top-nav" aria-label="主导航">
<Link href="/" aria-current={!current ? "page" : undefined}>文档中心</Link>
{collections.map(({ name, entry }) => <Link key={name} href={`/docs/${entry.slug}`} aria-current={collection === name ? "page" : undefined}>{collectionLabel(name)}</Link>)}
</nav>
<button
ref={trigger}
className="search-trigger"
aria-label="搜索文档"
onClick={openSearch}
>
<Icon name="search" />
<span>{collection ? `搜索${collectionLabel(collection)}…` : "搜索全部文档…"}</span>
<kbd>⌘ K</kbd>
</button>
<button
className="logout-button"
aria-label="退出登录"
onClick={async () => {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (response.ok) {
// Drop cached private document data when ending the session.
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
window.location.assign("/login");
}
}}
>
退出
</button>
</header>
<div className="mobile-bar">
<button
onClick={() => setMenu(!menu)}
aria-expanded={menu}
aria-controls="mobile-navigation"
>
<Icon name={menu ? "close" : "menu"} size={16} /> <span>{collection ? collectionLabel(collection) : "全部文档"}</span>
<span>{menu ? "−" : "+"}</span>
</button>
</div>
{menu && (
<div className="mobile-navigation" id="mobile-navigation">
<Navigation
docs={docs}
current={current}
onNavigate={() => setMenu(false)}
/>
</div>
)}
{children}
<dialog
ref={dialog}
className="search-dialog"
aria-label="搜索文档"
onClick={(e) => {
if (e.target === e.currentTarget) closeSearch();
}}
onClose={() => trigger.current?.focus()}
>
<div className="search-input-row">
<Icon name="search" />
<input
ref={input}
value={query}
placeholder={collection ? `搜索${collectionLabel(collection)}的内容…` : "搜索全部文档、命令或概念…"}
aria-label="搜索文档内容"
aria-controls="search-results"
onChange={(e) => {
setQuery(e.target.value);
setSelected(0);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelected((i) =>
Math.max(0, Math.min(i + 1, results.length - 1)),
);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelected((i) => Math.max(i - 1, 0));
}
if (e.key === "Enter") {
e.preventDefault();
dialog.current
?.querySelector<HTMLAnchorElement>(
`[data-result="${selected}"]`,
)
?.click();
}
}}
/>
<button onClick={closeSearch} aria-label="关闭搜索">
Esc
</button>
</div>
{searchStatus && <p className="search-status" role="status">{searchStatus}</p>}
<div id="search-results" className="search-results">
<p className="eyebrow" role="status">
{query ? `${results.length} 篇匹配文档` : "浏览文档"}
</p>
{results.map((d, i) => (
<Link
data-result={i}
className={
selected === i ? "search-result selected" : "search-result"
}
onClick={closeSearch}
key={d.slug}
href={`/docs/${d.slug}`}
>
<span>{d.group}</span>
<strong>
{d.title} <span aria-hidden="true">↗</span>
</strong>
<small>{d.description}</small>
</Link>
))}
{!results.length && (
<p className="empty-search">
没有找到相关内容,请尝试其他关键词。
</p>
)}
</div>
<div className="search-footer">
↑ ↓ 选择 <span>Enter 打开 · Esc 关闭</span>
</div>
</dialog>
</div>
);
}