Files
endorphin-tabularium-docs/apps/docs/src/components/Shell.tsx
T
codex-bot 787491622b
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m20s
Replace collection select with horizontal category dropdown navigation
2026-10-28 19:38:07 +08:00

255 lines
8.7 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 { CategoryNavigation } from "./CategoryNavigation";
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="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("/overview") ? "概览" : d.title.replace("(草案)", "").replace(/^\d+\s*·\s*/, "")}</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 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 />
<CategoryNavigation docs={docs} current={current} />
<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>
);
}