720 lines
22 KiB
TypeScript
720 lines
22 KiB
TypeScript
import * as React from "react"
|
|
import { createPortal } from "react-dom"
|
|
import { Icon } from "@icones/react"
|
|
import {
|
|
getDocHref,
|
|
getDocPackages,
|
|
getDocSectionLabel,
|
|
getDocsHomeHref,
|
|
resolveDocLocation,
|
|
} from "../content/registry"
|
|
import type {
|
|
DocLocation,
|
|
DocPage,
|
|
DocTocItem,
|
|
} from "../content/types"
|
|
import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
|
|
import { mdxComponents } from "../components/mdx"
|
|
import { useLocale, useTranslate } from "../lib/locale"
|
|
|
|
const noTocItems: readonly DocTocItem[] = []
|
|
|
|
export default function ArticlePage() {
|
|
const t = useTranslate()
|
|
const docLocation = resolveDocLocation(window.location.pathname)
|
|
const visibleTocIds = useVisibleTocIds(docLocation?.page.toc ?? noTocItems)
|
|
const [mobilePanel, setMobilePanel] = React.useState<
|
|
"navigation" | "toc" | null
|
|
>(null)
|
|
const mobilePanelRef = React.useRef<HTMLDivElement>(null)
|
|
const navigationTriggerRef = React.useRef<HTMLButtonElement>(null)
|
|
const tocTriggerRef = React.useRef<HTMLButtonElement>(null)
|
|
|
|
React.useEffect(() => {
|
|
if (!mobilePanel) return
|
|
|
|
const previousOverflow = document.body.style.overflow
|
|
document.body.style.overflow = "hidden"
|
|
const unavailableAt = window.matchMedia(
|
|
mobilePanel === "navigation" ? "(min-width: 48rem)" : "(min-width: 64rem)"
|
|
)
|
|
|
|
const animationFrame = window.requestAnimationFrame(() => {
|
|
mobilePanelRef.current
|
|
?.querySelector<HTMLElement>(
|
|
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
)
|
|
?.focus()
|
|
})
|
|
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault()
|
|
setMobilePanel(null)
|
|
const trigger =
|
|
mobilePanel === "navigation"
|
|
? navigationTriggerRef.current
|
|
: tocTriggerRef.current
|
|
trigger?.focus()
|
|
return
|
|
}
|
|
|
|
if (event.key !== "Tab") return
|
|
|
|
const focusableElements = Array.from(
|
|
mobilePanelRef.current?.querySelectorAll<HTMLElement>(
|
|
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
) ?? []
|
|
)
|
|
const firstElement = focusableElements[0]
|
|
const lastElement = focusableElements.at(-1)
|
|
|
|
if (!firstElement || !lastElement) return
|
|
|
|
if (event.shiftKey && document.activeElement === firstElement) {
|
|
event.preventDefault()
|
|
lastElement.focus()
|
|
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
|
event.preventDefault()
|
|
firstElement.focus()
|
|
}
|
|
}
|
|
|
|
function handleBreakpointChange(event: MediaQueryListEvent) {
|
|
if (event.matches) setMobilePanel(null)
|
|
}
|
|
|
|
document.addEventListener("keydown", handleKeyDown)
|
|
unavailableAt.addEventListener("change", handleBreakpointChange)
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(animationFrame)
|
|
document.removeEventListener("keydown", handleKeyDown)
|
|
unavailableAt.removeEventListener("change", handleBreakpointChange)
|
|
document.body.style.overflow = previousOverflow
|
|
}
|
|
}, [mobilePanel])
|
|
|
|
if (!docLocation) return <ArticleNotFound />
|
|
|
|
const currentIndex = docLocation.package.pages.indexOf(docLocation.page)
|
|
const previousPage = docLocation.package.pages[currentIndex - 1]
|
|
const nextPage = docLocation.package.pages[currentIndex + 1]
|
|
const previous = previousPage
|
|
? {
|
|
locale: docLocation.locale,
|
|
package: docLocation.package,
|
|
page: previousPage,
|
|
}
|
|
: undefined
|
|
const next = nextPage
|
|
? {
|
|
locale: docLocation.locale,
|
|
package: docLocation.package,
|
|
page: nextPage,
|
|
}
|
|
: undefined
|
|
|
|
return (
|
|
<>
|
|
<header
|
|
data-article-sticky-header
|
|
className="z-10 h-16 border-b bg-white/70 backdrop-blur-sm md:sticky md:top-0 dark:border-slate-800 dark:bg-slate-950/80"
|
|
>
|
|
<Container className="flex size-full items-center gap-4">
|
|
<HomeButton href={getDocsHomeHref(docLocation.locale)} />
|
|
<SlashLine />
|
|
<h1 className="truncate">{docLocation.package.label}</h1>
|
|
<Actions className="ml-auto" />
|
|
</Container>
|
|
</header>
|
|
|
|
<section
|
|
data-article-sticky-header
|
|
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 backdrop-blur-sm md:hidden dark:border-slate-800 dark:bg-slate-950/80"
|
|
>
|
|
<button
|
|
ref={navigationTriggerRef}
|
|
type="button"
|
|
className="inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
|
|
aria-controls="article-mobile-navigation"
|
|
aria-expanded={mobilePanel === "navigation"}
|
|
onClick={() =>
|
|
setMobilePanel((current) =>
|
|
current === "navigation" ? null : "navigation"
|
|
)
|
|
}
|
|
>
|
|
<Icon name="tabler:menu" className="size-5" />
|
|
<span>{t({ zh: "文档菜单", us: "Docs Menu" })}</span>
|
|
</button>
|
|
<button
|
|
ref={tocTriggerRef}
|
|
type="button"
|
|
className="ml-auto inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
|
|
aria-controls="article-mobile-toc"
|
|
aria-expanded={mobilePanel === "toc"}
|
|
onClick={() =>
|
|
setMobilePanel((current) => (current === "toc" ? null : "toc"))
|
|
}
|
|
>
|
|
<span>{t({ zh: "本页内容", us: "On this page" })}</span>
|
|
<Icon name="tabler:list" className="size-5 rotate-180" />
|
|
</button>
|
|
</section>
|
|
|
|
{mobilePanel &&
|
|
typeof document !== "undefined" &&
|
|
createPortal(
|
|
<div className="fixed inset-0 z-50">
|
|
<div
|
|
className="absolute inset-0 bg-slate-950/50 backdrop-blur-[1px] dark:bg-black/70"
|
|
aria-hidden="true"
|
|
onClick={() => {
|
|
const trigger =
|
|
mobilePanel === "navigation"
|
|
? navigationTriggerRef.current
|
|
: tocTriggerRef.current
|
|
setMobilePanel(null)
|
|
window.requestAnimationFrame(() => trigger?.focus())
|
|
}}
|
|
/>
|
|
<div
|
|
ref={mobilePanelRef}
|
|
id={
|
|
mobilePanel === "navigation"
|
|
? "article-mobile-navigation"
|
|
: "article-mobile-toc"
|
|
}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={
|
|
mobilePanel === "navigation"
|
|
? t({ zh: "文档导航", us: "Documentation navigation" })
|
|
: t({ zh: "本页内容", us: "On this page" })
|
|
}
|
|
className={[
|
|
"absolute inset-y-0 w-[min(88vw,35rem)] overflow-y-auto overscroll-contain bg-white p-7 shadow-2xl dark:bg-slate-950 dark:shadow-black/60",
|
|
mobilePanel === "navigation" ? "left-0" : "right-0",
|
|
].join(" ")}
|
|
onClick={(event) => {
|
|
if (
|
|
event.target instanceof Element &&
|
|
event.target.closest("a[href]")
|
|
) {
|
|
setMobilePanel(null)
|
|
}
|
|
}}
|
|
>
|
|
{mobilePanel === "navigation" ? (
|
|
<nav aria-label="Documentation navigation">
|
|
<Sidebar current={docLocation} />
|
|
</nav>
|
|
) : (
|
|
<TableOfContents
|
|
activeIds={visibleTocIds}
|
|
items={docLocation.page.toc}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
<div className="flex w-full">
|
|
<div
|
|
data-slot="left"
|
|
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r bg-slate-50 md:block dark:border-slate-800 dark:bg-slate-900/50"
|
|
>
|
|
<aside className="ml-auto w-full max-w-(--max-lw) p-8">
|
|
<Sidebar current={docLocation} />
|
|
</aside>
|
|
</div>
|
|
<div
|
|
data-slot="center"
|
|
className="min-w-0 flex-1 sm:flex-[0_1_var(--cw)]"
|
|
>
|
|
<main className="w-full max-w-(--cw) px-4 pt-8 pb-16 md:px-22">
|
|
<Header docLocation={docLocation} />
|
|
<ArticleContent page={docLocation.page} />
|
|
<Footer next={next} previous={previous} />
|
|
</main>
|
|
</div>
|
|
<div
|
|
data-slot="right"
|
|
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-rw) flex-[5_1_var(--min-rw)] lg:block"
|
|
>
|
|
<div className="h-full w-full max-w-(--max-rw)">
|
|
<TableOfContents
|
|
activeIds={visibleTocIds}
|
|
items={docLocation.page.toc}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<footer className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
|
<Container className="flex h-full w-full items-center">
|
|
Copyright © 2026
|
|
</Container>
|
|
</footer>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function Sidebar({ current }: { current: DocLocation }) {
|
|
const overview = current.package.pages.find(
|
|
(page) => page.section === "overview"
|
|
)
|
|
const groups = current.package.pages
|
|
.filter((page) => page.section !== "overview")
|
|
.reduce<Array<{ section: DocPage["section"]; pages: DocPage[] }>>(
|
|
(result, page) => {
|
|
const group = result.at(-1)
|
|
if (group?.section === page.section) {
|
|
group.pages.push(page)
|
|
} else {
|
|
result.push({ section: page.section, pages: [page] })
|
|
}
|
|
return result
|
|
},
|
|
[]
|
|
)
|
|
|
|
return (
|
|
<ul>
|
|
{overview && (
|
|
<li>
|
|
<SidebarPageLink current={current} page={overview} prominent />
|
|
</li>
|
|
)}
|
|
{groups.map((group) => (
|
|
<li className="not-first:mt-8" key={group.section}>
|
|
<h2 className="font-semibold">
|
|
{getDocSectionLabel(group.section, current.locale)}
|
|
</h2>
|
|
<ul className="mt-1.5">
|
|
{group.pages.map((page) => (
|
|
<li key={`${page.section}/${page.slug}`}>
|
|
<SidebarPageLink current={current} page={page} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
function SidebarPageLink({
|
|
current,
|
|
page,
|
|
prominent = false,
|
|
}: {
|
|
current: DocLocation
|
|
page: DocPage
|
|
prominent?: boolean
|
|
}) {
|
|
return (
|
|
<a
|
|
className={`block py-1 transition-colors hover:text-indigo-500 aria-[current=page]:text-indigo-500 dark:hover:text-indigo-300 dark:aria-[current=page]:text-indigo-400 ${prominent ? "font-semibold text-slate-950 dark:text-slate-100" : "text-gray-600 aria-[current=page]:font-medium dark:text-slate-400"}`}
|
|
href={getDocHref(current.package, page)}
|
|
aria-current={
|
|
current.page.section === page.section && current.page.slug === page.slug
|
|
? "page"
|
|
: undefined
|
|
}
|
|
>
|
|
{page.title}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function Header({ docLocation }: { docLocation: DocLocation }) {
|
|
return (
|
|
<header className="mb-12 border-b pb-12">
|
|
<ul className="flex items-center gap-2">
|
|
<li>
|
|
<a
|
|
href={getDocHref(docLocation.package)}
|
|
className="transition-colors hover:text-indigo-500 dark:hover:text-indigo-300"
|
|
>
|
|
{docLocation.package.label}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<span className="text-slate-200 dark:text-slate-700">/</span>
|
|
</li>
|
|
{docLocation.page.section !== "overview" && (
|
|
<>
|
|
<li>
|
|
<span className="text-slate-500 dark:text-slate-400">
|
|
{getDocSectionLabel(
|
|
docLocation.page.section,
|
|
docLocation.locale
|
|
)}
|
|
</span>
|
|
</li>
|
|
<li>
|
|
<span className="text-slate-200 dark:text-slate-700">/</span>
|
|
</li>
|
|
</>
|
|
)}
|
|
<li>
|
|
<span className="text-indigo-500 dark:text-indigo-400">
|
|
{docLocation.page.title}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
<h1 className="mt-4 text-4xl">{docLocation.page.title}</h1>
|
|
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
|
{docLocation.page.description}
|
|
</p>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
function ArticleContent({ page }: { page: DocPage }) {
|
|
const Content = page.Content
|
|
|
|
return (
|
|
<article
|
|
data-article-content
|
|
className="space-y-5 text-slate-700 dark:text-slate-300 [&_hr]:my-12 [&_hr]:border-slate-200 dark:[&_hr]:border-slate-800 [&_li]:leading-7 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_p]:leading-7 [&_strong]:font-semibold [&_strong]:text-slate-950 dark:[&_strong]:text-slate-100 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-200 [&_td]:p-3 dark:[&_td]:border-slate-800 [&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-50 [&_th]:p-3 [&_th]:text-left dark:[&_th]:border-slate-800 dark:[&_th]:bg-slate-900 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
|
|
>
|
|
<Content components={mdxComponents} />
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function TableOfContents({
|
|
activeIds,
|
|
items,
|
|
}: {
|
|
activeIds: readonly string[]
|
|
items: readonly DocTocItem[]
|
|
}) {
|
|
const t = useTranslate()
|
|
const label = t({ zh: "本页内容", us: "On this page" })
|
|
const rootRef = React.useRef<HTMLElement>(null)
|
|
const railRef = React.useRef<HTMLDivElement>(null)
|
|
const [marker, setMarker] = React.useState<{
|
|
height: number
|
|
top: number
|
|
} | null>(null)
|
|
const activeKey = activeIds.join("\n")
|
|
|
|
React.useLayoutEffect(() => {
|
|
const root = rootRef.current
|
|
const rail = railRef.current
|
|
if (!root || !rail) return
|
|
|
|
function measureMarker() {
|
|
const activeIdSet = new Set(activeIds)
|
|
const activeRows = Array.from(
|
|
root!.querySelectorAll<HTMLElement>("[data-toc-id]")
|
|
).filter((row) => activeIdSet.has(row.dataset.tocId ?? ""))
|
|
|
|
if (activeRows.length === 0 || rail!.offsetHeight === 0) {
|
|
setMarker(null)
|
|
return
|
|
}
|
|
|
|
const railRect = rail!.getBoundingClientRect()
|
|
const firstRect = activeRows[0]!.getBoundingClientRect()
|
|
const lastRect = activeRows.at(-1)!.getBoundingClientRect()
|
|
setMarker({
|
|
top: firstRect.top - railRect.top,
|
|
height: lastRect.bottom - firstRect.top,
|
|
})
|
|
}
|
|
|
|
measureMarker()
|
|
const resizeObserver = new ResizeObserver(measureMarker)
|
|
resizeObserver.observe(root)
|
|
window.addEventListener("resize", measureMarker)
|
|
|
|
return () => {
|
|
resizeObserver.disconnect()
|
|
window.removeEventListener("resize", measureMarker)
|
|
}
|
|
}, [activeKey, activeIds])
|
|
|
|
return (
|
|
<aside
|
|
ref={rootRef}
|
|
aria-label={label}
|
|
className="relative max-h-full overflow-y-auto p-8"
|
|
>
|
|
<div
|
|
ref={railRef}
|
|
className="absolute top-8 bottom-6 left-8 hidden w-px bg-slate-200 md:block dark:bg-slate-800"
|
|
>
|
|
<div
|
|
className="absolute left-0 w-px bg-indigo-500 transition-[height,transform,opacity] duration-200 dark:bg-indigo-400"
|
|
style={{
|
|
height: marker?.height ?? 0,
|
|
opacity: marker ? 1 : 0,
|
|
transform: `translateY(${marker?.top ?? 0}px)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="md:ps-4">
|
|
<h2 className="shrink-0">{label}</h2>
|
|
<ul className="mt-2">
|
|
{items.map((item) => (
|
|
<TocItem activeIds={activeIds} item={item} key={item.id} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function TocItem({
|
|
activeIds,
|
|
item,
|
|
}: {
|
|
activeIds: readonly string[]
|
|
item: DocTocItem
|
|
}) {
|
|
const active = activeIds.includes(item.id)
|
|
|
|
return (
|
|
<li>
|
|
<TocAnchor
|
|
active={active}
|
|
href={`#${item.id}`}
|
|
itemId={item.id}
|
|
title={item.title}
|
|
/>
|
|
{item.items && item.items.length > 0 && (
|
|
<ul className="ps-4">
|
|
{item.items.map((child) => (
|
|
<TocItem activeIds={activeIds} item={child} key={child.id} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
function TocAnchor({
|
|
active,
|
|
href,
|
|
itemId,
|
|
title,
|
|
}: {
|
|
active: boolean
|
|
href: string
|
|
itemId: string
|
|
title: string
|
|
}) {
|
|
return (
|
|
<div className="py-1" data-toc-id={itemId}>
|
|
<a
|
|
href={href}
|
|
data-active={active || undefined}
|
|
className="text-slate-600 transition-colors hover:text-indigo-500 data-active:font-medium data-active:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 dark:data-active:text-indigo-400"
|
|
>
|
|
{title}
|
|
</a>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type TocVisibilityEntry = {
|
|
id: string
|
|
parentIds: readonly string[]
|
|
}
|
|
|
|
function useVisibleTocIds(items: readonly DocTocItem[]) {
|
|
const entries = React.useMemo(() => flattenTocItems(items), [items])
|
|
const [activeIds, setActiveIds] = React.useState<readonly string[]>([])
|
|
|
|
React.useEffect(() => {
|
|
if (entries.length === 0) {
|
|
setActiveIds([])
|
|
return
|
|
}
|
|
|
|
let animationFrame: number | undefined
|
|
const articleElement = document.querySelector<HTMLElement>(
|
|
"[data-article-content]"
|
|
)
|
|
if (!articleElement) return
|
|
const article = articleElement
|
|
|
|
function update() {
|
|
animationFrame = undefined
|
|
const headings = entries.flatMap((entry) => {
|
|
const element = document.getElementById(entry.id)
|
|
return element ? [{ element, entry }] : []
|
|
})
|
|
const viewportTop = getVisibleViewportTop()
|
|
const viewportBottom = window.innerHeight
|
|
const articleBottom = article.getBoundingClientRect().bottom
|
|
const visibleIds = new Set<string>()
|
|
|
|
headings.forEach(({ element, entry }, index) => {
|
|
const sectionTop = element.getBoundingClientRect().top
|
|
const sectionBottom =
|
|
headings[index + 1]?.element.getBoundingClientRect().top ??
|
|
articleBottom
|
|
|
|
if (sectionBottom > viewportTop && sectionTop < viewportBottom) {
|
|
entry.parentIds.forEach((id) => visibleIds.add(id))
|
|
visibleIds.add(entry.id)
|
|
}
|
|
})
|
|
|
|
const nextActiveIds = entries
|
|
.map((entry) => entry.id)
|
|
.filter((id) => visibleIds.has(id))
|
|
setActiveIds((current) =>
|
|
arraysEqual(current, nextActiveIds) ? current : nextActiveIds
|
|
)
|
|
}
|
|
|
|
function scheduleUpdate() {
|
|
if (animationFrame !== undefined) return
|
|
animationFrame = window.requestAnimationFrame(update)
|
|
}
|
|
|
|
scheduleUpdate()
|
|
const resizeObserver = new ResizeObserver(scheduleUpdate)
|
|
resizeObserver.observe(article)
|
|
window.addEventListener("scroll", scheduleUpdate, { passive: true })
|
|
window.addEventListener("resize", scheduleUpdate)
|
|
|
|
return () => {
|
|
if (animationFrame !== undefined) {
|
|
window.cancelAnimationFrame(animationFrame)
|
|
}
|
|
resizeObserver.disconnect()
|
|
window.removeEventListener("scroll", scheduleUpdate)
|
|
window.removeEventListener("resize", scheduleUpdate)
|
|
}
|
|
}, [entries])
|
|
|
|
return activeIds
|
|
}
|
|
|
|
function flattenTocItems(
|
|
items: readonly DocTocItem[],
|
|
parentIds: readonly string[] = []
|
|
): readonly TocVisibilityEntry[] {
|
|
return items.flatMap((item) => [
|
|
{ id: item.id, parentIds },
|
|
...flattenTocItems(item.items ?? [], [...parentIds, item.id]),
|
|
])
|
|
}
|
|
|
|
function getVisibleViewportTop() {
|
|
return Array.from(
|
|
document.querySelectorAll<HTMLElement>("[data-article-sticky-header]")
|
|
).reduce((bottom, element) => {
|
|
const rect = element.getBoundingClientRect()
|
|
return rect.top <= 0 && rect.bottom > 0
|
|
? Math.max(bottom, rect.bottom)
|
|
: bottom
|
|
}, 0)
|
|
}
|
|
|
|
function arraysEqual(left: readonly string[], right: readonly string[]) {
|
|
return (
|
|
left.length === right.length &&
|
|
left.every((value, index) => value === right[index])
|
|
)
|
|
}
|
|
|
|
function Footer({
|
|
next,
|
|
previous,
|
|
}: {
|
|
next?: DocLocation
|
|
previous?: DocLocation
|
|
}) {
|
|
return (
|
|
<footer className="mt-12 flex flex-col gap-4 border-t border-dashed pt-12 md:grid md:grid-cols-2 md:gap-8">
|
|
{previous && (
|
|
<Surround
|
|
href={getDocHref(previous.package, previous.page)}
|
|
title={previous.page.title}
|
|
description={previous.page.description}
|
|
align="left"
|
|
/>
|
|
)}
|
|
{next && (
|
|
<Surround
|
|
href={getDocHref(next.package, next.page)}
|
|
title={next.page.title}
|
|
description={next.page.description}
|
|
align="right"
|
|
/>
|
|
)}
|
|
</footer>
|
|
)
|
|
}
|
|
|
|
function Surround({
|
|
href,
|
|
title,
|
|
description,
|
|
align = "right",
|
|
}: {
|
|
title: string
|
|
description: string
|
|
align?: "left" | "right"
|
|
href: string
|
|
}) {
|
|
return (
|
|
<a
|
|
href={href}
|
|
className={[
|
|
"group flex min-w-0 flex-col gap-1 rounded-xl border p-4",
|
|
"transition-colors hover:bg-slate-50 dark:hover:bg-slate-900",
|
|
align === "left"
|
|
? "col-start-1 items-start text-left"
|
|
: "col-start-2 items-end text-right",
|
|
].join(" ")}
|
|
>
|
|
<div className="flex size-8 items-center justify-center rounded-full border bg-slate-50 text-sm font-bold transition-colors group-hover:border-indigo-500 group-hover:bg-indigo-100 group-hover:text-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:group-hover:border-indigo-400 dark:group-hover:bg-indigo-950/70 dark:group-hover:text-indigo-300">
|
|
{align === "left" ? (
|
|
<Icon name="tabler:arrow-left" />
|
|
) : (
|
|
<Icon name="tabler:arrow-right" />
|
|
)}
|
|
</div>
|
|
<h2 className="mt-4 w-full min-w-0 truncate text-lg">{title}</h2>
|
|
<p className="text-md mt-1 line-clamp-2 w-full min-w-0 break-all text-slate-500 dark:text-slate-400">
|
|
{description}
|
|
</p>
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function ArticleNotFound() {
|
|
const locale = useLocale()
|
|
const t = useTranslate()
|
|
|
|
return (
|
|
<main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center px-6">
|
|
<h1 className="text-4xl">
|
|
{t({ zh: "未找到文档", us: "Document not found" })}
|
|
</h1>
|
|
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
|
{t({
|
|
zh: "请求的文档包或页面不存在。",
|
|
us: "The requested package or document does not exist.",
|
|
})}
|
|
</p>
|
|
<a
|
|
className="mt-6 w-fit text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
|
href={getDocHref(getDocPackages(locale)[0]!)}
|
|
>
|
|
{t({ zh: "打开文档", us: "Open the documentation" })}
|
|
</a>
|
|
</main>
|
|
)
|
|
}
|