Refine Tabularium into a readable light documentation workspace
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m26s
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m26s
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language = "text",
|
||||
}: {
|
||||
code: string;
|
||||
language?: string;
|
||||
}) {
|
||||
const [status, setStatus] = useState("复制");
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setStatus("已复制");
|
||||
} catch {
|
||||
setStatus("复制失败,请手动选择");
|
||||
}
|
||||
setTimeout(() => setStatus("复制"), 2400);
|
||||
}
|
||||
return (
|
||||
<div className="code-block">
|
||||
<div className="code-toolbar">
|
||||
<span>{language === "sh" ? "Terminal" : language}</span>
|
||||
<button onClick={copy} aria-label="复制代码">
|
||||
{status}
|
||||
</button>
|
||||
<span className="sr-only" role="status">
|
||||
{status === "复制" ? "" : status}
|
||||
</span>
|
||||
</div>
|
||||
<pre>
|
||||
<code>
|
||||
{code
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
.map((line, i) => (
|
||||
<span className="code-line" key={i}>
|
||||
<span className="line-number" aria-hidden="true">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>
|
||||
{/^(pnpm|uv)\b/.test(line) ? (
|
||||
<>
|
||||
<span className="code-command">{line.split(" ")[0]}</span>
|
||||
{line.slice(line.indexOf(" "))}
|
||||
</>
|
||||
) : (
|
||||
line || "\u00a0"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
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}
|
||||
{d.slug.includes("draft") && (
|
||||
<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/02-mounting-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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user