Files
endorphin-tabularium-docs/apps/docs/src/components/Shell.tsx
T
codex-bot 9fcfbdb694
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m28s
Protect Tabularium with persistent authenticator-code login
2026-10-27 21:48:16 +08:00

251 lines
7.4 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 { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
import { sectionFor } from "@/lib/navigation";
export function Brand() {
return (
<Link className="brand" href="/" aria-label="Tabularium 首页">
<span className="brand-mark" aria-hidden="true">
<i />
<i />
<i />
</span>
<span>
Endor <span className="brand-divider">/</span>{" "}
<strong>Tabularium</strong>
</span>
<span className="docs-badge">文档</span>
</Link>
);
}
export function Navigation({
docs,
current,
onNavigate,
}: {
docs: DocLink[];
current?: string;
onNavigate?: () => void;
}) {
const groups = [...new Set(docs.map((d) => sectionFor(d.slug).group))];
return (
<nav aria-label="文档导航">
<Link
onClick={onNavigate}
className={`nav-home ${!current ? "selected" : ""}`}
href="/"
>
▦ <span>文档概览</span>
</Link>
{groups.map((group) => (
<div className="nav-group" key={group}>
<p>{group}</p>
{docs
.filter((d) => sectionFor(d.slug).group === group)
.map((d) => (
<Link
onClick={onNavigate}
aria-current={d.slug === current ? "page" : undefined}
className="nav-item"
href={`/docs/${d.slug}`}
key={d.slug}
>
{d.title}
{sectionFor(d.slug).label.includes("草案") && (
<span className="tiny-dot" aria-label="草案" />
)}
</Link>
))}
</div>
))}
<div className="nav-note">
<span className="status-dot" /> 与代码一起演进
<p>文字、设计与图形的共同档案。</p>
</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 [selected, setSelected] = useState(0);
const [menu, setMenu] = useState(false);
const results = docs.filter((d) =>
`${d.title} ${d.searchText}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
);
function openSearch() {
setQuery("");
setSelected(0);
dialog.current?.showModal();
input.current?.focus();
}
function closeSearch() {
dialog.current?.close();
trigger.current?.focus();
}
useEffect(() => {
function keyboard(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
dialog.current?.showModal();
input.current?.focus();
}
}
document.addEventListener("keydown", keyboard);
return () => document.removeEventListener("keydown", keyboard);
}, []);
return (
<>
<a className="skip-link" href="#content">
跳到正文
</a>
<header className="site-header">
<Brand />
<nav className="top-nav" aria-label="主导航">
<Link href="/">文档概览</Link>
<Link href="/docs/00-endor-concepts-draft">架构设计</Link>
<a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">
仓库 ↗
</a>
</nav>
<button
ref={trigger}
className="search-trigger"
aria-label="搜索文档"
onClick={openSearch}
>
<span aria-hidden="true">⌕</span>
<span>搜索文档…</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"
>
☰ <span>文档目录</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">
<span aria-hidden="true">⌕</span>
<input
ref={input}
value={query}
placeholder="搜索文档、命令或概念…"
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>
<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>{sectionFor(d.slug).group}</span>
<strong>
{d.title} <span aria-hidden="true">↗</span>
</strong>
<small>{sectionFor(d.slug).description}</small>
</Link>
))}
{!results.length && (
<p className="empty-search">
没有找到相关文档。试试「节点」「SVG」或「pnpm」。
</p>
)}
</div>
<div className="search-footer">
↑ ↓ 选择 <span>Enter 打开 · Esc 关闭</span>
</div>
</dialog>
</>
);
}