261 lines
9.1 KiB
TypeScript
261 lines
9.1 KiB
TypeScript
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>
|
||
);
|
||
}
|