chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
"use client"
import { FileTextIcon, LayoutGridIcon } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useI18n } from "@/i18n/provider"
import type { Category } from "@/lib/api/support-community"
import { cn } from "@/lib/utils"
type CommunityCategoryNavProps = {
categories: Category[]
active: number | "all"
loading?: boolean
failed?: boolean
onChange: (value: number | "all") => void
onRetry?: () => void
onSelected?: () => void
}
export function CommunityCategoryNav(props: CommunityCategoryNavProps) {
const t = useI18n()
return (
<aside className="hidden min-w-0 self-start xl:sticky xl:top-14 xl:block xl:h-[calc(100svh-3.5rem)] xl:border-r" aria-label={t("supportPublic.posts.categoryNavigation")}>
<nav className="h-full overflow-hidden border-b bg-background xl:border-b-0">
<ScrollArea className="h-full">
<div className="grid gap-0.5 px-3 py-4">
<CommunityCategoryMenuContent {...props} />
</div>
</ScrollArea>
</nav>
</aside>
)
}
export function CommunityCategoryMenuContent({ categories, active, loading = false, failed = false, onChange, onRetry, onSelected }: CommunityCategoryNavProps) {
const t = useI18n()
const choose = (value: number | "all") => {
onChange(value)
onSelected?.()
}
return (
<>
<button type="button" data-support-mobile-menu-close="true" data-category-id="all" onClick={() => choose("all")} className={categoryItemClassName(active === "all")} aria-pressed={active === "all"}>
<LayoutGridIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="truncate">{t("supportPublic.common.allCategories")}</span>
</button>
{categories.map((category) => (
<button key={category.id} type="button" data-support-mobile-menu-close="true" data-category-id={category.id} onClick={() => choose(category.id)} className={categoryItemClassName(active === category.id)} aria-pressed={active === category.id}>
<FileTextIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="truncate">{category.name}</span>
</button>
))}
{loading ? <div className="px-2.5 py-2 text-xs text-muted-foreground">{t("supportPublic.loading.categories")}</div> : null}
{failed ? <button type="button" className="mt-2 px-2.5 text-left text-sm text-destructive underline-offset-4 hover:underline" onClick={onRetry}>{t("supportPublic.posts.categoriesFailed")}</button> : null}
</>
)
}
function categoryItemClassName(active: boolean) {
return cn(
"relative flex min-w-0 items-center gap-2 rounded-md text-left text-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "bg-primary/10 font-medium text-primary" : "text-muted-foreground",
"min-h-9 px-2.5 py-1.5"
)
}

View File

@@ -0,0 +1,64 @@
"use client"
import { type ReactNode } from "react"
import { SupportHeader } from "@/app/(support)/support/_components/support-header"
import { CommunityCategoryMenuContent, CommunityCategoryNav } from "@/app/(support)/support/_components/community-category-nav"
import { useCommunityCategoryRoute } from "@/app/(support)/support/_components/support-community-route"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
export function CommunityFrame({
active,
categoryRoute,
children,
toc,
}: {
active?: number | "all"
categoryRoute: ReturnType<typeof useCommunityCategoryRoute>
children: ReactNode
toc?: ReactNode
}) {
const t = useI18n()
const categoryNavigation = (
<CommunityCategoryMenuContent
categories={categoryRoute.categories}
active={active ?? categoryRoute.activeCategoryId}
loading={categoryRoute.categoriesLoading}
failed={categoryRoute.categoriesFailed}
onChange={categoryRoute.changeCategory}
onRetry={categoryRoute.loadCategories}
/>
)
return (
<main className="min-h-svh bg-background text-foreground">
<SupportHeader
section="community"
mobileNavigation={{
title: t("supportPublic.posts.categoryNavigation"),
content: <div className="grid gap-0.5">{categoryNavigation}</div>,
}}
/>
<div
className={cn(
"mx-auto grid max-w-[var(--support-docs-max-width)] xl:grid-cols-[var(--support-doc-nav-width)_minmax(0,1fr)]",
toc ? "2xl:grid-cols-[var(--support-doc-nav-wide-width)_minmax(0,1fr)_var(--support-doc-toc-width)]" : "2xl:grid-cols-[var(--support-doc-nav-wide-width)_minmax(0,1fr)]"
)}
>
<CommunityCategoryNav
categories={categoryRoute.categories}
active={active ?? categoryRoute.activeCategoryId}
loading={categoryRoute.categoriesLoading}
failed={categoryRoute.categoriesFailed}
onChange={categoryRoute.changeCategory}
onRetry={categoryRoute.loadCategories}
/>
<section className="min-w-0 bg-background">
{children}
</section>
{toc ? <div className="hidden 2xl:block">{toc}</div> : null}
</div>
</main>
)
}

View File

@@ -0,0 +1,49 @@
"use client"
import { useCallback } from "react"
import { SupportEmptyState } from "@/app/(support)/support/_components/support-ui"
import { PostCard } from "@/app/(support)/support/community/posts/_components/post-ui"
import { LoadMore } from "@/components/load-more"
import { useI18n } from "@/i18n/provider"
import { fetchPosts, type Post } from "@/lib/api/support-community"
type CommunityPostListProps = {
emptyText: string
limit?: number
query?: Record<string, string | number | undefined>
resetKey: string
}
export function CommunityPostList({
emptyText,
limit = 20,
query,
resetKey,
}: CommunityPostListProps) {
const t = useI18n()
const loadPosts = useCallback(({ cursor }: { cursor: string; force: boolean }) => {
return fetchPosts({
...query,
cursor,
limit,
})
}, [limit, query])
return (
<LoadMore<Post>
resetKey={resetKey}
initialHasMore
initialLoad
labels={{
loadMore: t("supportPublic.actions.loadMore"),
noMore: t("supportPublic.actions.noMore"),
loading: t("supportPublic.loading.posts"),
error: t("supportPublic.empty.postsFailed"),
}}
loadPage={loadPosts}
renderItems={(items) => items.map((item) => <PostCard key={item.id} item={item} compact />)}
renderEmpty={() => <SupportEmptyState text={emptyText} />}
/>
)
}

View File

@@ -0,0 +1,74 @@
"use client"
import { useEffect } from "react"
import { toast } from "sonner"
import { useImageLightboxOptional } from "@/components/image-lightbox"
import { SupportArticleContent } from "@/app/(support)/support/_components/support-article-content"
import { useI18n } from "@/i18n/provider"
import { articleHeadingId } from "@/lib/support-article"
export function PostArticleContent({ content, contentType, id, articleHeadingIds = true }: { content: string; contentType?: string; id: string; articleHeadingIds?: boolean }) {
const resolvedContentType = contentType || postContentType(content)
useHtmlArticleEnhancements(id, content, resolvedContentType, articleHeadingIds)
return <SupportArticleContent id={id} content={content} contentType={resolvedContentType} articleHeadingIds={articleHeadingIds} />
}
function postContentType(content: string) {
return /<\/?[a-z][\s\S]*>/i.test(content) ? "html" : "markdown"
}
function useHtmlArticleEnhancements(id: string, content: string, contentType: string, articleHeadingIds: boolean) {
const t = useI18n()
const lightbox = useImageLightboxOptional()
useEffect(() => {
if (contentType !== "html") return
const container = document.getElementById(id)
if (!container) return
const cleanup: Array<() => void> = []
if (articleHeadingIds) {
container.querySelectorAll<HTMLElement>("h2, h3").forEach((heading, index) => {
heading.id = articleHeadingId(heading.textContent || "", index)
heading.classList.add("scroll-mt-20")
})
}
container.querySelectorAll<HTMLPreElement>("pre").forEach((block) => {
block.classList.add("group", "relative")
if (block.querySelector("[data-support-copy-code]")) return
const button = document.createElement("button")
button.type = "button"
button.className = "not-typeset absolute right-2 top-2 rounded-md border border-border bg-background/90 px-2 py-1 text-xs text-muted-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 focus:opacity-100"
button.dataset.notTypeset = "true"
button.dataset.supportCopyCode = "true"
button.textContent = t("supportPublic.help.copyCode")
button.setAttribute("aria-label", t("supportPublic.help.copyCode"))
const copy = () => void navigator.clipboard.writeText(block.querySelector("code")?.textContent || block.textContent || "").then(() => toast.success(t("supportPublic.toast.codeCopied")))
button.addEventListener("click", copy)
block.appendChild(button)
cleanup.push(() => { button.removeEventListener("click", copy); button.remove() })
})
const articleImages = Array.from(container.querySelectorAll<HTMLImageElement>("img"))
articleImages.forEach((image, imageIndex) => {
if (!lightbox) return
image.classList.add("cursor-zoom-in")
const open = () => lightbox.openGallery(articleImages.map((item) => ({
src: item.currentSrc || item.src,
alt: item.alt,
})), imageIndex)
image.addEventListener("click", open)
cleanup.push(() => image.removeEventListener("click", open))
})
container.querySelectorAll<HTMLTableElement>("table").forEach((table) => {
if (table.parentElement?.classList.contains("typeset-scroll")) return
const wrapper = document.createElement("div")
wrapper.className = "typeset-scroll"
table.before(wrapper)
wrapper.appendChild(table)
cleanup.push(() => {
wrapper.before(table)
wrapper.remove()
})
})
return () => cleanup.forEach((dispose) => dispose())
}, [articleHeadingIds, content, contentType, id, lightbox, t])
}

View File

@@ -0,0 +1,311 @@
"use client"
import { Children, isValidElement, useEffect, useId, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"
import { useTheme } from "next-themes"
import rehypeHighlight from "rehype-highlight"
import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { toast } from "sonner"
import { useImageLightboxOptional, type ImageLightboxContextValue, type ImageLightboxItem } from "@/components/image-lightbox"
import { SafeRichHTML } from "@/components/safe-rich-html"
import { useI18n } from "@/i18n/provider"
import { articleHeadingId } from "@/lib/support-article"
let mermaidRenderQueue = Promise.resolve()
function enqueueMermaidRender(task: () => Promise<void>) {
const next = mermaidRenderQueue.then(task, task)
mermaidRenderQueue = next.catch(() => undefined)
return next
}
function registerArticleLightboxItem(element: HTMLElement) {
element.dataset.supportLightboxItem = ""
}
function unregisterArticleLightboxItem(element: HTMLElement) {
delete element.dataset.supportLightboxItem
}
function resolveArticleLightboxItem(element: HTMLElement): ImageLightboxItem | null {
const svg = element.querySelector<SVGSVGElement>("svg")
if (svg) {
const figure = element.closest<HTMLElement>(".support-mermaid")
return {
src: `mermaid:${svg.id}`,
alt: element.querySelector<HTMLElement>("[role='img']")?.getAttribute("aria-label") ?? "",
svg: svg.outerHTML,
backgroundColor: figure ? window.getComputedStyle(figure).backgroundColor : undefined,
}
}
if (element instanceof HTMLImageElement) {
const src = element.getAttribute("src")
return src ? { src, alt: element.alt } : null
}
return null
}
function openArticleLightbox(lightbox: ImageLightboxContextValue, target: HTMLElement) {
const article = target.closest(".typeset-support-docs")
if (!article) return
const elements = Array.from(article.querySelectorAll<HTMLElement>("[data-support-lightbox-item]"))
const entries = elements.flatMap((element) => {
const item = resolveArticleLightboxItem(element)
return item ? [{ element, item }] : []
})
const index = entries.findIndex((entry) => entry.element === target)
if (index < 0) return
const items = entries.map((entry) => entry.item)
if (typeof lightbox.openGallery === "function") {
lightbox.openGallery(items, index)
} else {
const item = items[index]
lightbox.open(item.src, item.alt)
}
}
type MarkdownNode = {
children?: MarkdownNode[]
properties?: Record<string, unknown>
tagName?: string
type: string
value?: string
}
function nodeText(node: MarkdownNode): string {
if (node.type === "text") return node.value ?? ""
return node.children?.map(nodeText).join("") ?? ""
}
function rehypeArticleHeadingIds() {
return (tree: MarkdownNode) => {
let headingIndex = 0
const visit = (node: MarkdownNode) => {
if (node.type === "element" && (node.tagName === "h2" || node.tagName === "h3")) {
node.properties = {
...node.properties,
className: "scroll-mt-20",
id: articleHeadingId(nodeText(node), headingIndex),
}
headingIndex += 1
}
node.children?.forEach(visit)
}
visit(tree)
}
}
function supportArticleUrlTransform(url: string, key: string) {
const transformed = defaultUrlTransform(url)
if (!transformed || (key !== "href" && key !== "src")) return transformed
if (transformed.startsWith("/")) return transformed
try {
const parsed = new URL(transformed, "https://support.local")
return parsed.protocol === "http:" || parsed.protocol === "https:" ? transformed : ""
} catch {
return ""
}
}
function reactNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node)
if (Array.isArray(node)) return node.map(reactNodeText).join("")
if (isValidElement<{ children?: ReactNode }>(node)) return reactNodeText(node.props.children)
return ""
}
function mermaidDefinition(children: ReactNode) {
const code = Children.toArray(children).find(
(child) => isValidElement<{ className?: string }>(child) && child.props.className?.split(/\s+/).includes("language-mermaid"),
)
if (!isValidElement<{ children?: ReactNode }>(code)) return null
return reactNodeText(code.props.children).replace(/\n$/, "")
}
function MermaidDiagram({ definition }: { definition: string }) {
const t = useI18n()
const { resolvedTheme } = useTheme()
const lightbox = useImageLightboxOptional()
const previewRef = useRef<HTMLButtonElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const reactId = useId()
const diagramId = useMemo(() => `support-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`, [reactId])
const renderKey = `${definition}\n${resolvedTheme}`
const [readyRenderKey, setReadyRenderKey] = useState<string | null>(null)
const isReady = readyRenderKey === renderKey
useEffect(() => {
const container = containerRef.current
const preview = previewRef.current
if (!container) return
let cancelled = false
const status = document.createElement("div")
status.className = "support-mermaid-status"
status.setAttribute("role", "status")
status.textContent = t("supportPublic.help.mermaidLoading")
container.setAttribute("aria-busy", "true")
container.replaceChildren(status)
void enqueueMermaidRender(async () => {
try {
const { default: mermaid } = await import("mermaid")
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: resolvedTheme === "dark" ? "dark" : "default",
themeVariables: resolvedTheme === "dark" ? {
actorBkg: "#27272a",
actorBorder: "#8b5cf6",
actorLineColor: "#a1a1aa",
actorTextColor: "#f4f4f5",
edgeLabelBackground: "#18181b",
labelBoxBkgColor: "#27272a",
labelBoxBorderColor: "#8b5cf6",
labelTextColor: "#f4f4f5",
lineColor: "#a1a1aa",
loopTextColor: "#f4f4f5",
noteBkgColor: "#292524",
noteTextColor: "#f4f4f5",
primaryTextColor: "#f4f4f5",
signalColor: "#d4d4d8",
signalTextColor: "#f4f4f5",
tertiaryTextColor: "#f4f4f5",
} : undefined,
fontFamily: "var(--font-app-sans), Arial, sans-serif",
flowchart: { useMaxWidth: true },
})
const valid = await mermaid.parse(definition, { suppressErrors: true })
if (!valid) throw new Error("Invalid Mermaid definition")
const { svg } = await mermaid.render(diagramId, definition)
if (cancelled) return
container.innerHTML = svg
container.setAttribute("aria-busy", "false")
if (lightbox && preview) {
registerArticleLightboxItem(preview)
setReadyRenderKey(renderKey)
}
} catch {
if (cancelled) return
const error = document.createElement("div")
error.className = "support-mermaid-status support-mermaid-error"
error.setAttribute("role", "alert")
error.textContent = t("supportPublic.help.mermaidError")
container.setAttribute("aria-busy", "false")
container.replaceChildren(error)
}
})
return () => {
cancelled = true
if (preview) unregisterArticleLightboxItem(preview)
}
}, [definition, diagramId, lightbox, renderKey, resolvedTheme, t])
return (
<figure className="support-mermaid not-typeset" data-not-typeset>
<button
ref={previewRef}
type="button"
disabled={!isReady}
className="support-mermaid-preview"
aria-label={t("supportPublic.help.mermaidPreview")}
onClick={(event) => {
if (lightbox) openArticleLightbox(lightbox, event.currentTarget)
}}
>
<div
ref={containerRef}
className="support-mermaid-canvas"
role="img"
aria-label={t("supportPublic.help.mermaidDiagram")}
/>
</button>
</figure>
)
}
type SupportArticleContentProps = {
content: string
contentType?: string
id: string
articleHeadingIds?: boolean
}
export function SupportArticleContent({ content, contentType = "markdown", id, articleHeadingIds = true }: SupportArticleContentProps) {
const t = useI18n()
const lightbox = useImageLightboxOptional()
const components = useMemo<Components>(() => ({
a: ({ children, href, ...props }) => (
<a {...props} href={href} target="_blank" rel="noreferrer noopener">
{children}
</a>
),
img: ({ alt, src, ...props }) => (
// Markdown images can use arbitrary remote hosts, so Next Image cannot optimize them safely.
// eslint-disable-next-line @next/next/no-img-element
<img
{...props}
alt={alt ?? ""}
src={src}
ref={(image) => {
if (image && lightbox && typeof src === "string") {
registerArticleLightboxItem(image)
return () => unregisterArticleLightboxItem(image)
}
}}
className={lightbox && typeof src === "string" ? "cursor-zoom-in" : undefined}
onClick={lightbox && typeof src === "string" ? (event) => openArticleLightbox(lightbox, event.currentTarget) : undefined}
/>
),
pre: ({ children, ...props }) => {
const definition = mermaidDefinition(children)
if (definition !== null) return <MermaidDiagram definition={definition} />
const copy = (event: MouseEvent<HTMLButtonElement>) => {
const code = event.currentTarget.parentElement?.querySelector("code")?.textContent ?? ""
void navigator.clipboard.writeText(code).then(() => toast.success(t("supportPublic.toast.codeCopied")))
}
return (
<pre {...props} className="group relative">
{children}
<button
type="button"
className="not-typeset absolute right-2 top-2 rounded-md border border-border bg-background/90 px-2 py-1 text-xs text-muted-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 focus:opacity-100"
data-not-typeset
aria-label={t("supportPublic.help.copyCode")}
onClick={copy}
>
{t("supportPublic.help.copyCode")}
</button>
</pre>
)
},
table: ({ children, ...props }) => (
<div className="typeset-scroll">
<table {...props}>{children}</table>
</div>
),
}), [lightbox, t])
if (contentType === "html") {
return <SafeRichHTML id={id} html={content} articleHeadingIds={articleHeadingIds} unstyled className="typeset typeset-support-docs" />
}
return (
<div id={id} className="typeset typeset-support-docs">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[
...(articleHeadingIds ? [rehypeArticleHeadingIds] : []),
[rehypeHighlight, { detect: false, plainText: ["text", "txt", "plaintext"] }],
]}
skipHtml
urlTransform={supportArticleUrlTransform}
components={components}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"
import { useI18n } from "@/i18n/provider"
import { articleHeadingId, markdownHeadingText } from "@/lib/support-article"
import { cn } from "@/lib/utils"
export function PublicArticleToc({ articleId, content, contentType = "markdown" }: { articleId: string; content: string; contentType?: string }) {
const t = useI18n()
const tocRef = useRef<HTMLElement>(null)
const headings = useMemo(() => getArticleTocHeadings(content, contentType), [content, contentType])
const [activeId, setActiveId] = useState("")
useEffect(() => {
if (!headings.length || !articleId) return
let frame = 0
const findHeadingElement = (headingId: string) => document.getElementById(articleId)?.querySelector<HTMLElement>(`#${CSS.escape(headingId)}`) ?? null
const syncActiveHeading = () => {
frame = 0
const anchorOffset = 88
let nextId = headings[0].id
for (const heading of headings) {
const element = findHeadingElement(heading.id)
if (!element || element.getBoundingClientRect().top > anchorOffset) break
nextId = heading.id
}
setActiveId((current) => current === nextId ? current : nextId)
}
const scheduleSync = () => {
if (frame) return
frame = window.requestAnimationFrame(syncActiveHeading)
}
const syncHashTarget = () => {
const hashId = decodeURIComponent(window.location.hash.slice(1))
if (headings.some((heading) => heading.id === hashId)) {
findHeadingElement(hashId)?.scrollIntoView()
}
}
const initialFrame = window.requestAnimationFrame(() => {
syncHashTarget()
syncActiveHeading()
})
const delayedSync = window.setTimeout(() => {
syncHashTarget()
syncActiveHeading()
}, 50)
window.addEventListener("scroll", scheduleSync, { passive: true })
window.addEventListener("resize", scheduleSync)
return () => {
window.cancelAnimationFrame(initialFrame)
window.clearTimeout(delayedSync)
if (frame) window.cancelAnimationFrame(frame)
window.removeEventListener("scroll", scheduleSync)
window.removeEventListener("resize", scheduleSync)
}
}, [articleId, headings])
const scrollToHeading = (event: ReactMouseEvent<HTMLAnchorElement>, headingId: string) => {
const element = document.getElementById(articleId)?.querySelector<HTMLElement>(`#${CSS.escape(headingId)}`)
if (!element) return
event.preventDefault()
element.scrollIntoView()
window.history.replaceState(null, "", `#${encodeURIComponent(headingId)}`)
setActiveId(headingId)
}
useEffect(() => {
const container = tocRef.current
if (!container || !activeId) return
const activeLink = Array.from(container.querySelectorAll<HTMLAnchorElement>("[data-toc-id]"))
.find((link) => link.dataset.tocId === activeId)
if (!activeLink) return
const containerRect = container.getBoundingClientRect()
const linkRect = activeLink.getBoundingClientRect()
if (linkRect.top >= containerRect.top && linkRect.bottom <= containerRect.bottom) return
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
container.scrollTo({
top: activeLink.offsetTop - container.clientHeight / 2 + activeLink.clientHeight / 2,
behavior: reducedMotion ? "auto" : "smooth",
})
}, [activeId])
return (
<aside ref={tocRef} className="sticky top-14 max-h-[calc(100svh-3.5rem)] overflow-y-auto px-5 py-12">
<div>
<div className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("supportPublic.help.toc")}</div>
{headings.length ? headings.map((item, index) => (
<a
key={`${item.title}-${index}`}
href={`#${item.id}`}
data-toc-id={item.id}
aria-current={activeId === item.id ? "location" : undefined}
onClick={(event) => scrollToHeading(event, item.id)}
className={cn(
"block border-l py-1.5 pl-3 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-foreground",
item.level === 3 && "pl-6",
activeId === item.id && "border-primary bg-muted/50 font-medium text-foreground"
)}
>
<span className="line-clamp-3">{item.title}</span>
</a>
)) : <div className="text-sm text-muted-foreground">{t("supportPublic.help.noToc")}</div>}
</div>
</aside>
)
}
export function hasArticleTocHeadings(content: string, contentType?: string) {
return getArticleTocHeadings(content, contentType).length > 0
}
function getArticleTocHeadings(content: string, contentType = "markdown") {
return contentType === "html"
? Array.from(content.matchAll(/<h([23])[^>]*>([\s\S]*?)<\/h\1>/gi)).map((match, index) => {
const title = match[2].replace(/<[^>]+>/g, "").trim()
return { level: Number(match[1]), title, id: articleHeadingId(title, index) }
})
: Array.from(content.matchAll(/^(#{2,3})\s+(.+)$/gm)).map((match, index) => {
const title = markdownHeadingText(match[2])
return { level: match[1].length, title, id: articleHeadingId(title, index) }
})
}

View File

@@ -0,0 +1,35 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import { SessionProvider, useSession } from "@/components/session-provider"
import { type AuthSession } from "@/lib/auth"
type SupportAuthContextValue = {
session: AuthSession | null
ready: boolean
signingOut: boolean
refreshSession: () => Promise<void>
signOut: () => Promise<void>
}
const SupportAuthContext = createContext<SupportAuthContextValue | null>(null)
export function SupportAuthProvider({ children }: { children: ReactNode }) {
return (
<SessionProvider>
<SupportSessionProvider>{children}</SupportSessionProvider>
</SessionProvider>
)
}
function SupportSessionProvider({ children }: { children: ReactNode }) {
const { session, ready, signingOut, refreshSession, signOut } = useSession()
return <SupportAuthContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}>{children}</SupportAuthContext.Provider>
}
export function useSupportAuth() {
const context = useContext(SupportAuthContext)
if (!context) throw new Error("useSupportAuth must be used within SupportAuthProvider")
return context
}

View File

@@ -0,0 +1,89 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { fetchCategories, postHref, postsHref, type Category } from "@/lib/api/support-community"
import { fetchSupportMe } from "@/lib/api/support"
import { readSession } from "@/lib/auth"
export function useCommunityCategoryRoute() {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const [categories, setCategories] = useState<Category[]>([])
const [categoriesLoading, setCategoriesLoading] = useState(true)
const [categoriesFailed, setCategoriesFailed] = useState(false)
const categorySlug = categorySlugFromPath(pathname) || searchParams.get("category") || ""
const activeCategory = categorySlug ? categories.find((item) => item.slug === categorySlug) : undefined
const activeCategoryId: number | "all" = categorySlug ? activeCategory?.id ?? "all" : "all"
const loadCategories = useCallback(() => {
setCategoriesLoading(true)
setCategoriesFailed(false)
void fetchCategories()
.then(setCategories)
.catch(() => {
setCategories([])
setCategoriesFailed(true)
})
.finally(() => setCategoriesLoading(false))
}, [])
useEffect(() => {
const timer = window.setTimeout(loadCategories, 0)
return () => window.clearTimeout(timer)
}, [loadCategories])
const changeCategory = useCallback((value: number | "all") => {
const params = new URLSearchParams(searchParams.toString())
params.delete("category")
const query = params.toString()
if (value === "all") {
router.push(`${postsHref()}${query ? `?${query}` : ""}`)
return
}
const category = categories.find((item) => item.id === value)
router.push(`/support/community/categories/${encodeURIComponent(category?.slug || String(value))}${query ? `?${query}` : ""}`)
}, [categories, router, searchParams])
return {
activeCategory,
activeCategoryId,
categories,
categoriesFailed,
categoriesLoading,
categorySlug,
changeCategory,
loadCategories,
}
}
export function communityPostHref(id: number) {
return postHref(id)
}
export function categorySlugFromPath(pathname: string) {
const segments = pathname.split("/").filter(Boolean)
if (segments[0] !== "support" || segments[1] !== "community" || segments[2] !== "categories" || segments.length !== 4) {
return ""
}
return decodeURIComponent(segments[3] || "")
}
export async function ensureSupportLogin() {
if (!readSession()?.accessToken) {
window.location.assign(`/support/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`)
throw new Error("login required")
}
try {
await fetchSupportMe()
} catch (error) {
window.location.assign(`/support/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`)
throw error
}
}
export function getSupportLoginDestination(next: string | null) {
return next?.startsWith("/support/") && !next.startsWith("/support/login") ? next : postsHref()
}

View File

@@ -0,0 +1,275 @@
"use client"
import {
ChevronDownIcon,
LoaderCircleIcon,
LogOutIcon,
PencilIcon,
UserRoundIcon,
} from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useSupportAuth } from "@/app/(support)/support/_components/support-auth-provider"
import { SupportMobileMenu } from "@/app/(support)/support/_components/support-mobile-menu"
import { ThemeToggle } from "@/components/theme-toggle"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useI18n } from "@/i18n/provider"
import { fetchSupportConfig, type SupportNavigationMenuItem } from "@/lib/api/support-config"
import { fetchPublicConfig } from "@/lib/api/config"
import { cn } from "@/lib/utils"
export type SupportHeaderSection = "home" | "help" | "community" | "login"
const sectionTitleKey: Record<SupportHeaderSection, string> = {
home: "supportPublic.home.badge",
help: "supportPublic.help.title",
community: "supportPublic.nav.community",
login: "supportPublic.nav.login",
}
const supportNavItemClass = "h-8 rounded-md px-2.5 text-sm"
function defaultSupportNavigationMenu(t: ReturnType<typeof useI18n>): SupportNavigationMenuItem[] {
return [
{ id: "home", title: t("supportPublic.nav.home"), url: "/support", openInNewWindow: false, visible: true, sortNo: 10 },
{ id: "help", title: t("supportPublic.nav.help"), url: "/support/docs", openInNewWindow: false, visible: true, sortNo: 20 },
{ id: "community", title: t("supportPublic.nav.community"), url: "/support/community/posts", openInNewWindow: false, visible: true, sortNo: 30 },
]
}
export function SupportHeader({
section,
leading,
mobileNavigation,
className,
}: {
section: SupportHeaderSection
leading?: ReactNode
mobileNavigation?: {
title: string
content: ReactNode
}
className?: string
}) {
const t = useI18n()
const pathname = usePathname()
const fallbackNavigation = useMemo(() => defaultSupportNavigationMenu(t), [t])
const [navigationItems, setNavigationItems] = useState<SupportNavigationMenuItem[]>(fallbackNavigation)
const [companyName, setCompanyName] = useState<string>("")
useEffect(() => {
void fetchPublicConfig()
.then((cfg) => {
if (cfg?.companyName) {
setCompanyName(cfg.companyName)
}
})
.catch(() => {})
}, [])
useEffect(() => {
let ignore = false
void fetchSupportConfig()
.then((config) => {
if (!ignore && config.navigationMenu.length > 0) {
setNavigationItems(config.navigationMenu)
}
})
.catch(() => {
if (!ignore) {
setNavigationItems(fallbackNavigation)
}
})
return () => {
ignore = true
}
}, [fallbackNavigation])
const isActive = (href: string) => {
return href === "/support"
? pathname === href
: pathname === href || pathname.startsWith(`${href}/`)
}
return (
<header
className={cn(
"sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80",
className
)}
>
<div className="mx-auto flex h-14 max-w-[var(--support-docs-max-width)] items-center gap-3 px-4 sm:px-6 md:px-8 xl:px-6">
<SupportMobileMenu navigationItems={navigationItems} pathname={pathname} secondary={mobileNavigation} />
{leading}
<Link
href="/support"
className="flex shrink-0 items-center gap-2 font-semibold tracking-tight"
>
<span>{companyName || "AGENT DESK"}</span>
<span className="hidden border-l pl-2 font-normal text-muted-foreground sm:inline">
{t(sectionTitleKey[section])}
</span>
</Link>
<nav
className="ml-auto hidden items-center gap-1 sm:flex"
aria-label={t("supportPublic.home.badge")}
>
{navigationItems.map((item) => (
<SupportNavigationLink
key={item.id}
item={item}
active={isActive(item.url)}
/>
))}
</nav>
<div className="ml-auto flex items-center gap-1 sm:ml-4">
<SupportAccountControl />
<ThemeToggle
variant="ghost"
size="icon-sm"
className="size-8 rounded-md"
/>
</div>
</div>
</header>
)
}
function SupportNavigationLink({ item, active }: { item: SupportNavigationMenuItem; active: boolean }) {
const className = cn(
buttonVariants({
variant: active ? "secondary" : "ghost",
size: "sm",
}),
supportNavItemClass,
"max-w-36"
)
const content = <span className="truncate">{item.title}</span>
if (item.openInNewWindow || /^https?:\/\//i.test(item.url)) {
return (
<a
className={className}
href={item.url}
target={item.openInNewWindow ? "_blank" : undefined}
rel={item.openInNewWindow ? "noreferrer" : undefined}
>
{content}
</a>
)
}
return (
<Link className={className} href={item.url}>
{content}
</Link>
)
}
function SupportAccountControl() {
const t = useI18n()
const { ready, session, signingOut, signOut } = useSupportAuth()
if (!ready) {
return (
<div
className="h-7 w-20 animate-pulse rounded-md bg-muted"
aria-label={t("supportPublic.account.loading")}
/>
)
}
if (!session) {
return (
<Link
className={cn(
buttonVariants({ size: "sm" }),
supportNavItemClass,
"hover:bg-primary/90"
)}
href="/support/login"
>
{t("supportPublic.nav.login")}
</Link>
)
}
const user = session.user
const displayName = user.nickname || user.username
const fallback = displayName.slice(0, 1).toUpperCase() || "U"
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<button
className="inline-flex h-8 max-w-48 items-center gap-2 rounded-md px-1 py-1 text-sm font-medium text-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
/>
}
aria-label={t("supportPublic.account.openMenu")}
>
<Avatar className="size-[30px]">
<AvatarImage src={user.avatar} alt={displayName} />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
<span className="hidden max-w-24 truncate sm:inline sm:max-w-32">
{displayName}
</span>
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 min-w-56 rounded-md">
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left">
<Avatar className="size-8">
<AvatarImage src={user.avatar} alt={displayName} />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{displayName}</p>
<p className="truncate text-xs text-muted-foreground">{user.username}</p>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link href="/support/profile" />} className="h-8 px-2">
<UserRoundIcon />
{t("supportPublic.account.profile")}
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/support/profile/edit" />} className="h-8 px-2">
<PencilIcon />
{t("supportPublic.account.editProfile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={signingOut}
onClick={() => void signOut()}
className="h-8 px-2"
variant="destructive"
>
{signingOut ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<LogOutIcon />
)}
{signingOut
? t("supportPublic.account.signingOut")
: t("supportPublic.account.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useCallback, useEffect, useState, type ComponentPropsWithoutRef, type MouseEvent as ReactMouseEvent } from "react"
import { type DocNavigationNode, type DocPage } from "@/lib/api/support"
export type DocPageNavigationHandler = (event: ReactMouseEvent<HTMLAnchorElement>, page: DocPage) => void
export function docPageHref(page: DocPage) {
return page.docPath || "/support/docs/"
}
function docPathFromPathname(pathname: string) {
const parts = pathname.split("/").filter(Boolean).map(decodeURIComponent)
const docsIndex = parts.findIndex((part, index) => (part === "docs" || part === "help") && parts[index - 1] === "support")
if (docsIndex < 0 || parts.length === docsIndex + 1) return ""
return `/support/docs/${parts.slice(docsIndex + 1).map(encodeURIComponent).join("/")}/`
}
export function useSupportDocRoute(pathname: string) {
const [activePath, setActivePath] = useState(() => docPathFromPathname(pathname))
useEffect(() => {
const handlePopState = () => setActivePath(docPathFromPathname(window.location.pathname))
window.addEventListener("popstate", handlePopState)
return () => window.removeEventListener("popstate", handlePopState)
}, [])
const replace = useCallback((page: DocPage) => {
const target = docPageHref(page)
window.history.replaceState(null, "", target)
setActivePath(target)
}, [])
const navigate = useCallback<DocPageNavigationHandler>((event, page) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
const href = docPageHref(page)
if (window.location.pathname !== href) {
window.history.pushState(null, "", href)
setActivePath(href)
}
window.scrollTo({ top: 0, behavior: "auto" })
}, [])
return { activePath, navigate, replace }
}
export function flattenSupportDocNavigation(nodes: DocNavigationNode[], parentSegments: string[] = []): DocPage[] {
return nodes.flatMap((node) => [
{
...node,
summary: "",
contentType: "",
content: "",
coverUrl: "",
tags: [],
status: "published",
viewCount: 0,
helpfulCount: 0,
unhelpfulCount: 0,
publishedAt: "",
createdAt: "",
updatedAt: "",
docPath: `/support/docs/${[...parentSegments, node.slug].map(encodeURIComponent).join("/")}/`,
},
...flattenSupportDocNavigation(node.children, [...parentSegments, node.slug]),
])
}
export function SupportDocLink({
page,
onNavigate,
onClick,
...props
}: Omit<ComponentPropsWithoutRef<"a">, "href"> & {
page: DocPage
onNavigate: DocPageNavigationHandler
}) {
return (
<a
{...props}
href={docPageHref(page)}
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) onNavigate(event, page)
}}
/>
)
}

View File

@@ -0,0 +1,144 @@
"use client"
import { useEffect, useState, type ReactNode } from "react"
import Link from "next/link"
import { MenuIcon, XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { useI18n } from "@/i18n/provider"
import { fetchPublicConfig } from "@/lib/api/config"
import type { SupportNavigationMenuItem } from "@/lib/api/support-config"
import { cn } from "@/lib/utils"
type SupportMobileMenuProps = {
navigationItems: SupportNavigationMenuItem[]
pathname: string
secondary?: {
title: string
content: ReactNode
}
}
export function SupportMobileMenu({ navigationItems, pathname, secondary }: SupportMobileMenuProps) {
const t = useI18n()
const [open, setOpen] = useState(false)
const [companyName, setCompanyName] = useState("")
const triggerClassName = secondary ? "xl:hidden" : "sm:hidden"
const isActive = (href: string) => href === "/support" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`)
// The mobile sheet used to hardcode the product name while the desktop header
// read it from /api/config, so a renamed deployment showed two different names
// depending on the viewport.
useEffect(() => {
let cancelled = false
void fetchPublicConfig()
.then((config) => {
if (!cancelled) {
setCompanyName(config.companyName?.trim() || "")
}
})
.catch(() => {
// Leave it empty; the localized fallback below covers the failure.
})
return () => {
cancelled = true
}
}, [])
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={cn("size-8 rounded-md", triggerClassName)}
/>
}
aria-label={t("supportPublic.a11y.openMenu")}
>
<MenuIcon />
</SheetTrigger>
<SheetContent
side="left"
showCloseButton={false}
className="w-[min(88vw,22.5rem)] gap-0 rounded-r-md p-0 sm:max-w-sm"
>
<SheetHeader className="flex h-14 flex-row items-center justify-between border-b px-4 py-0">
<SheetTitle className="text-sm font-semibold tracking-tight">
{companyName || t("app.brand")}
</SheetTitle>
<Button
variant="ghost"
size="icon-sm"
className="size-8 rounded-md"
onClick={() => setOpen(false)}
aria-label={t("supportPublic.a11y.closeMenu")}
>
<XIcon />
</Button>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="px-4 py-4">
<div className="text-xs font-medium text-muted-foreground">
{t("supportPublic.nav.siteNavigation")}
</div>
<nav className="mt-2 grid gap-1" aria-label={t("supportPublic.nav.siteNavigation")}>
{navigationItems.map((item) => {
const active = isActive(item.url)
const external = item.openInNewWindow || /^https?:\/\//i.test(item.url)
const className = cn(
"flex h-9 items-center rounded-md px-2.5 text-sm font-medium transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "bg-primary/10 text-primary" : "text-muted-foreground"
)
if (external) {
return (
<a
key={item.id}
href={item.url}
target={item.openInNewWindow ? "_blank" : undefined}
rel={item.openInNewWindow ? "noreferrer" : undefined}
onClick={() => setOpen(false)}
className={className}
>
<span className="truncate">{item.title}</span>
</a>
)
}
return (
<Link
key={item.id}
href={item.url}
onClick={() => setOpen(false)}
className={className}
>
<span className="truncate">{item.title}</span>
</Link>
)
})}
</nav>
</div>
{secondary ? (
<div className="border-t px-4 py-4">
<div className="text-xs font-medium text-muted-foreground">
{secondary.title}
</div>
<div
className="mt-2"
onClickCapture={(event) => {
if ((event.target as HTMLElement).closest("a, [data-support-mobile-menu-close='true']")) {
setOpen(false)
}
}}
>
{secondary.content}
</div>
</div>
) : null}
</ScrollArea>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,18 @@
import { type ReactNode } from "react"
import { SupportHeader, type SupportHeaderSection } from "@/app/(support)/support/_components/support-header"
import { cn } from "@/lib/utils"
export function SupportPageShell({ children, section = "home" }: { children: ReactNode; section?: SupportHeaderSection }) {
return (
// <div className="min-h-svh overflow-x-clip bg-[#f7f9fc] text-foreground dark:bg-background">
<div className="min-h-svh overflow-x-clip bg-[#F4F6FF] text-foreground dark:bg-background">
<SupportHeader section={section} />
{children}
</div>
)
}
export function SupportPageContent({ children, className, width = "standard" }: { children?: ReactNode; className?: string; width?: "standard" | "docs" }) {
return <div className={cn("mx-auto px-5 sm:px-6 md:px-8 lg:px-10", width === "docs" ? "max-w-(--support-docs-max-width)" : "max-w-(--support-shell-max-width)", className)}>{children}</div>
}

View File

@@ -0,0 +1,67 @@
import { CheckCircle2Icon, SearchIcon } from "lucide-react"
import { type ReactNode } from "react"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
export function SupportSearchInput({
value,
onChange,
placeholder,
compact = false,
hero = false,
}: {
value: string
onChange: (value: string) => void
placeholder: string
compact?: boolean
hero?: boolean
}) {
return (
<div className="relative flex-1">
<SearchIcon className={cn("pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground", hero ? "left-4 size-5" : "left-3 size-4")} />
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
className={cn(
"bg-card",
hero && "h-13 rounded-md border-white bg-white pl-12 pr-4 text-base shadow-[0_12px_30px_rgba(36,117,252,.12)] focus-visible:ring-primary/25 dark:border-border dark:bg-card",
compact && "h-9 pl-9",
!hero && !compact && "h-11 pl-9"
)}
placeholder={placeholder}
/>
</div>
)
}
export function SupportFormField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="grid gap-2">
<span className="text-sm font-medium">{label}</span>
{children}
</label>
)
}
export function SupportEmptyState({ text, compact = false }: { text: string; compact?: boolean }) {
return <div className={cn("rounded-md border border-dashed bg-card p-8 text-center text-sm text-muted-foreground", compact && "border-0 p-5")}>{text}</div>
}
export function PostStatusBadge({ status }: { status: string }) {
const t = useI18n()
if (status === "resolved") return <Badge className="bg-emerald-600 text-white"><CheckCircle2Icon /> {t("supportPublic.status.resolved")}</Badge>
if (status === "closed") return <Badge variant="outline">{t("supportPublic.status.closed")}</Badge>
return <Badge variant="secondary">{t("supportPublic.status.normal")}</Badge>
}
export function SupportInfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border bg-card p-4 shadow-sm">
<div className="text-2xl font-semibold">{value}</div>
<div className="mt-1 text-sm text-muted-foreground">{label}</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { flattenSupportDocNavigation } from "@/app/(support)/support/_components/support-help-navigation"
import { fetchDocNavigation, fetchDocPage, type DocPage } from "@/lib/api/support"
export function useSupportDocReader(activePath: string, onDefaultPage: (page: DocPage) => void) {
const [page, setPage] = useState<DocPage | null>(null)
const [pages, setPages] = useState<DocPage[]>([])
const [navigationLoading, setNavigationLoading] = useState(true)
const [loadedSlug, setLoadedSlug] = useState("")
const [failed, setFailed] = useState(false)
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const initialPath = useRef(activePath)
const detailCache = useRef(new Map<number, DocPage>())
const detailRequestId = useRef(0)
const expandedInitialized = useRef(false)
useEffect(() => {
void fetchDocNavigation()
.then((tree) => {
setFailed(false)
const navigationPages = flattenSupportDocNavigation(tree)
setPages(navigationPages)
const target = navigationPages.find((item) => item.docPath === initialPath.current) || navigationPages[0]
if (!target) {
setPage(null)
setExpanded(new Set())
return
}
if (!initialPath.current) onDefaultPage(target)
})
.catch(() => {
setPage(null)
setFailed(true)
})
.finally(() => setNavigationLoading(false))
}, [onDefaultPage])
useEffect(() => {
if (!pages.length) return
const activePage = pages.find((item) => item.docPath === activePath)
if (!activePage) {
void Promise.resolve().then(() => {
setPage(null)
setFailed(Boolean(activePath))
})
return
}
const requestId = ++detailRequestId.current
const cached = detailCache.current.get(activePage.id)
const detailRequest = cached ? Promise.resolve(cached) : fetchDocPage(activePage.id)
void detailRequest
.then((detail) => {
if (requestId !== detailRequestId.current) return
detailCache.current.set(detail.id, detail)
setFailed(false)
setPage({ ...detail, docPath: activePage.docPath })
setLoadedSlug(activePath)
const activePagePath = [...docPageAncestorIds(pages, detail.id), detail.id]
setExpanded((current) => {
if (!expandedInitialized.current) {
expandedInitialized.current = true
return new Set(activePagePath)
}
const next = new Set(current)
activePagePath.forEach((id) => next.add(id))
return next
})
})
.catch(() => {
if (requestId !== detailRequestId.current) return
setPage(null)
setFailed(true)
setLoadedSlug(activePath)
})
}, [activePath, pages])
return {
page,
pages,
expanded,
setExpanded,
navigationLoading,
pageLoading: navigationLoading || Boolean(activePath && loadedSlug !== activePath),
failed,
}
}
function docPageAncestorIds(pages: DocPage[], pageId: number) {
const ancestors: number[] = []
const pagesById = new Map(pages.map((item) => [item.id, item]))
const visited = new Set<number>()
let parentId = pagesById.get(pageId)?.parentId ?? 0
while (parentId && !visited.has(parentId)) {
ancestors.push(parentId)
visited.add(parentId)
parentId = pagesById.get(parentId)?.parentId ?? 0
}
return ancestors
}