chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
47
web/app/(support)/layout.tsx
Normal file
47
web/app/(support)/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next"
|
||||
|
||||
import { ImageLightboxProvider } from "@/components/image-lightbox"
|
||||
import { ConfirmProvider } from "@/components/confirm-provider"
|
||||
import { SupportAuthProvider } from "@/app/(support)/support/_components/support-auth-provider"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { appFontVariables } from "@/lib/fonts"
|
||||
import { AppI18nProvider } from "@/i18n/provider"
|
||||
|
||||
import "./support.css"
|
||||
import "md-editor-rt/lib/style.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AgentDesk Support",
|
||||
description: "AgentDesk Support Center",
|
||||
}
|
||||
|
||||
export default function SupportRootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en-US" className={appFontVariables} suppressHydrationWarning>
|
||||
<body
|
||||
className="antialiased font-sans"
|
||||
>
|
||||
<AppI18nProvider>
|
||||
<ThemeProvider>
|
||||
<SupportAuthProvider>
|
||||
<ConfirmProvider>
|
||||
<ImageLightboxProvider>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<Toaster position="top-center" richColors />
|
||||
</TooltipProvider>
|
||||
</ImageLightboxProvider>
|
||||
</ConfirmProvider>
|
||||
</SupportAuthProvider>
|
||||
</ThemeProvider>
|
||||
</AppI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
118
web/app/(support)/support.css
Normal file
118
web/app/(support)/support.css
Normal file
@@ -0,0 +1,118 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "./typeset.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-app-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
--font-mono: var(--font-app-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--support-shell-max-width: 80rem;
|
||||
--support-docs-max-width: 100rem;
|
||||
--support-doc-nav-width: 18rem;
|
||||
--support-doc-nav-wide-width: 19rem;
|
||||
--support-doc-toc-width: 15rem;
|
||||
--support-article-width: 72rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: #2475fc;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: #2475fc;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: #6da2ff;
|
||||
--primary-foreground: #0f172a;
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: #6da2ff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.support-docs-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media (width >= 80rem) {
|
||||
.support-docs-grid {
|
||||
grid-template-columns: var(--support-doc-nav-width) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 96rem) {
|
||||
.support-docs-grid {
|
||||
grid-template-columns: var(--support-doc-nav-wide-width) minmax(0, 1fr) var(--support-doc-toc-width);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
64
web/app/(support)/support/_components/community-frame.tsx
Normal file
64
web/app/(support)/support/_components/community-frame.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
123
web/app/(support)/support/_components/support-article-toc.tsx
Normal file
123
web/app/(support)/support/_components/support-article-toc.tsx
Normal 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) }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
275
web/app/(support)/support/_components/support-header.tsx
Normal file
275
web/app/(support)/support/_components/support-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
144
web/app/(support)/support/_components/support-mobile-menu.tsx
Normal file
144
web/app/(support)/support/_components/support-mobile-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
web/app/(support)/support/_components/support-page-shell.tsx
Normal file
18
web/app/(support)/support/_components/support-page-shell.tsx
Normal 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>
|
||||
}
|
||||
67
web/app/(support)/support/_components/support-ui.tsx
Normal file
67
web/app/(support)/support/_components/support-ui.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
web/app/(support)/support/_components/use-support-help-reader.ts
Normal file
102
web/app/(support)/support/_components/use-support-help-reader.ts
Normal 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
|
||||
}
|
||||
25
web/app/(support)/support/chat/closed/page.tsx
Normal file
25
web/app/(support)/support/chat/closed/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { CheckCircle2Icon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export default function Page() {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<main className="flex min-h-svh items-center justify-center bg-muted px-6 text-foreground">
|
||||
<section className="grid max-w-sm justify-items-center gap-4 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900">
|
||||
<CheckCircle2Icon className="size-7" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.closedTitle")}</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{t("supportChat.closedDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
43
web/app/(support)/support/chat/page.tsx
Normal file
43
web/app/(support)/support/chat/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell"
|
||||
import {
|
||||
isEmbeddedInHost,
|
||||
SupportChatPanel,
|
||||
SupportChatShell,
|
||||
} from "@/components/support-chat/chat-shell"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
/**
|
||||
* This route serves two audiences. Embedded it is the widget iframe document,
|
||||
* where the host owns the size and the panel must fill the frame. Opened
|
||||
* directly it is a normal support page, so it gets the portal shell and a
|
||||
* bounded card instead of a full-viewport panel.
|
||||
*/
|
||||
export default function Page() {
|
||||
return isEmbeddedInHost() ? <SupportChatShell /> : <SupportChatPage />
|
||||
}
|
||||
|
||||
function SupportChatPage() {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<SupportPageShell section="home">
|
||||
<SupportPageContent className="pb-10 pt-6">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
{t("supportChat.pageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{t("supportChat.pageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-[calc(100dvh-15rem)] min-h-[28rem] flex-col overflow-hidden rounded-md border bg-card supports-not-[height:100dvh]:h-[calc(100vh-15rem)]">
|
||||
<SupportChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
5
web/app/(support)/support/community/categories/page.tsx
Normal file
5
web/app/(support)/support/community/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PostList } from "../posts/_components/post-list"
|
||||
|
||||
export default function SupportCommunityCategoryShellPage() {
|
||||
return <PostList />
|
||||
}
|
||||
5
web/app/(support)/support/community/page.tsx
Normal file
5
web/app/(support)/support/community/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PostList } from "./posts/_components/post-list"
|
||||
|
||||
export default function SupportCommunityPage() {
|
||||
return <PostList />
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { CommunityFrame } from "@/app/(support)/support/_components/community-frame"
|
||||
import { CommunityPostList } from "@/app/(support)/support/_components/community-post-list"
|
||||
import { SupportEmptyState as EmptyState } from "@/app/(support)/support/_components/support-ui"
|
||||
import { newPostHref } from "@/lib/api/support-community"
|
||||
import { useCommunityCategoryRoute } from "@/app/(support)/support/_components/support-community-route"
|
||||
import { PostListLoading } from "@/app/(support)/support/community/posts/_components/post-ui"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function PostList() {
|
||||
const t = useI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const categoryRoute = useCommunityCategoryRoute()
|
||||
const title = searchParams.get("title") || ""
|
||||
const [status, setStatus] = useState(searchParams.get("status") || "all")
|
||||
|
||||
const resetKey = [
|
||||
categoryRoute.activeCategoryId,
|
||||
categoryRoute.categorySlug,
|
||||
status,
|
||||
title,
|
||||
].join(":")
|
||||
const query = {
|
||||
categoryId: categoryRoute.activeCategoryId === "all" ? undefined : categoryRoute.activeCategoryId,
|
||||
status: status === "all" ? undefined : status,
|
||||
title,
|
||||
}
|
||||
const statusOptions = [
|
||||
{ value: "all", label: t("supportPublic.status.all") },
|
||||
{ value: "normal", label: t("supportPublic.status.normal") },
|
||||
{ value: "resolved", label: t("supportPublic.status.resolved") },
|
||||
]
|
||||
const categoryUnavailable = Boolean(categoryRoute.categorySlug && !categoryRoute.categoriesLoading && !categoryRoute.activeCategory)
|
||||
|
||||
return (
|
||||
<CommunityFrame categoryRoute={categoryRoute}>
|
||||
<div className="px-4 pt-3 sm:px-5 md:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="flex flex-col gap-2 border-b pb-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex min-w-0 gap-1.5 overflow-x-auto pb-1 sm:pb-0">
|
||||
{statusOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 whitespace-nowrap rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
status === option.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
aria-pressed={status === option.value}
|
||||
onClick={() => setStatus(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Link className={cn(buttonVariants(), "h-8 px-3 text-xs")} href={newPostHref()}>{t("supportPublic.actions.createPost")}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 sm:px-5 md:px-6 lg:px-8 2xl:px-10">
|
||||
{categoryRoute.categoriesLoading && categoryRoute.categorySlug ? <PostListLoading compact /> : null}
|
||||
{categoryUnavailable ? <EmptyState text={t("supportPublic.empty.noPostsMatched")} /> : null}
|
||||
{!categoryRoute.categoriesLoading && !categoryUnavailable ? (
|
||||
<CommunityPostList
|
||||
resetKey={resetKey}
|
||||
emptyText={t("supportPublic.empty.noPostsMatched")}
|
||||
query={query}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CommunityFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { EyeIcon, MessageCircleMoreIcon, ThumbsUpIcon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { postHref, type Post } from "@/lib/api/support-community"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
|
||||
export function PostCard({ item, compact = false }: { item: Post; compact?: boolean }) {
|
||||
const t = useI18n()
|
||||
return (
|
||||
<article className="border-b border-border/70 last:border-b-0">
|
||||
<div
|
||||
className={cn(
|
||||
"px-1",
|
||||
compact ? "py-2" : "py-3"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* <PostStatusPill status={item.status} /> */}
|
||||
<h2 className="min-w-0 flex-1 truncate text-[15px] font-semibold leading-6">
|
||||
<Link
|
||||
href={postHref(item.id)}
|
||||
className="truncate transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h2>
|
||||
{item.acceptedCommentId > 0 ? <span className="hidden h-5 shrink-0 items-center rounded bg-primary/10 px-1.5 text-[11px] font-medium text-primary sm:inline-flex">{t("supportPublic.comment.accepted")}</span> : null}
|
||||
</div>
|
||||
|
||||
{compact ? null : <p className="mt-1 line-clamp-1 text-sm leading-5 text-muted-foreground">{postExcerpt(item.content)}</p>}
|
||||
|
||||
<div className={cn("flex min-w-0 flex-wrap items-center gap-y-1 text-xs text-muted-foreground", compact ? "mt-1 gap-x-1.5" : "mt-2 gap-x-2")}>
|
||||
<span className="max-w-[10rem] truncate sm:max-w-[14rem]">{item.categoryName || t("supportPublic.common.uncategorized")}</span>
|
||||
<MetaSeparator />
|
||||
<span className="max-w-[10rem] truncate">{t("supportPublic.posts.createdBy", { name: item.userName || t("supportPublic.common.user") })}</span>
|
||||
<MetaSeparator />
|
||||
<span>{t("supportPublic.posts.updatedAt", { date: formatDateTime(item.updatedAt || item.createdAt) })}</span>
|
||||
<span className="hidden flex-1 sm:block" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PostMetric compact={compact} icon={<MessageCircleMoreIcon className="size-3.5" />} value={item.commentCount} label={t("supportPublic.posts.comments")} />
|
||||
<PostMetric compact={compact} icon={<ThumbsUpIcon className="size-3.5" />} value={item.reactionCount} label={t("supportPublic.posts.likes")} />
|
||||
<PostMetric compact={compact} className="hidden sm:inline-flex" icon={<EyeIcon className="size-3.5" />} value={item.viewCount} label={t("supportPublic.posts.views")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostMetric({ icon, value, label, className, compact = false }: { icon: ReactNode; value: number; label: string; className?: string; compact?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md bg-muted text-muted-foreground",
|
||||
compact ? "h-6 px-2 text-xs" : "h-7 px-2.5 text-xs",
|
||||
className
|
||||
)}
|
||||
title={label}
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
{icon}
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaSeparator() {
|
||||
return <span className="text-muted-foreground/35">/</span>
|
||||
}
|
||||
|
||||
export function PostStatusPill({ status }: { status: string }) {
|
||||
const t = useI18n()
|
||||
if (status === "resolved") return <span className="inline-flex h-5 items-center rounded bg-emerald-50 px-1.5 text-[11px] font-medium text-emerald-700">{t("supportPublic.status.resolved")}</span>
|
||||
if (status === "closed") return <span className="inline-flex h-5 items-center rounded bg-muted px-1.5 text-[11px] font-medium text-muted-foreground">{t("supportPublic.status.closed")}</span>
|
||||
return <span className="inline-flex h-5 items-center rounded bg-amber-50 px-1.5 text-[11px] font-medium text-amber-700">{t("supportPublic.status.normal")}</span>
|
||||
}
|
||||
|
||||
export function PostListLoading({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="divide-y divide-border/70" aria-hidden="true">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className={cn("px-1", compact ? "py-2" : "py-3")}>
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className={cn("h-5 w-4/5 animate-pulse rounded bg-muted", compact ? "mt-2" : "mt-3")} />
|
||||
<div className={cn("h-4 w-2/3 animate-pulse rounded bg-muted", compact ? "mt-1.5" : "mt-2")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function postExcerpt(content: string) {
|
||||
return content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { CheckCircle2Icon, ChevronDownIcon, CopyIcon, CornerDownRightIcon, FlagIcon, PencilIcon, ThumbsUpIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PostArticleContent } from "@/app/(support)/support/_components/post-article-content"
|
||||
import { ensureSupportLogin, communityPostHref } from "@/app/(support)/support/_components/support-community-route"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { acceptComment, createComment, deleteComment, fetchComments, reportComment, toggleReaction, updateComment, type Comment, type Post } from "@/lib/api/support-community"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
import type { ContentValue } from "@/components/content-editor"
|
||||
|
||||
export function CommentItem({ comment, post, currentUserId, onChanged }: { comment: Comment; post: Post; currentUserId: number; onChanged: () => void }) {
|
||||
const t = useI18n()
|
||||
const authorName = comment.authorName || t("supportPublic.common.user")
|
||||
const [replying, setReplying] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [replyContent, setReplyContent] = useState<ContentValue>({ mode: "html", raw: "" })
|
||||
const [editContent, setEditContent] = useState<ContentValue>({ mode: comment.contentType === "markdown" ? "markdown" : "html", raw: comment.content })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [replies, setReplies] = useState(comment.replies || [])
|
||||
const [repliesExpanded, setRepliesExpanded] = useState((comment.replies || []).length >= comment.replyCount)
|
||||
const isDeleted = comment.status === "deleted"
|
||||
const isAuthor = !isDeleted && currentUserId > 0 && comment.authorId === currentUserId
|
||||
const canAccept = !isDeleted && currentUserId > 0 && currentUserId === post.userId && !comment.isAccepted && comment.parentId === 0
|
||||
const isPostAuthor = comment.authorId === post.userId
|
||||
const isReply = comment.parentId > 0
|
||||
|
||||
useEffect(() => {
|
||||
setReplies(comment.replies || [])
|
||||
setRepliesExpanded((comment.replies || []).length >= comment.replyCount)
|
||||
}, [comment.id, comment.replyCount, comment.replies])
|
||||
|
||||
const submitReply = async () => {
|
||||
if (submitting || !replyContent.raw.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await ensureSupportLogin()
|
||||
await createComment({ postId: post.id, parentId: comment.id, contentType: replyContent.mode, content: replyContent.raw })
|
||||
setReplyContent({ mode: "html", raw: "" })
|
||||
setReplying(false)
|
||||
toast.success(t("supportPublic.toast.replyCreated"))
|
||||
onChanged()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (submitting || !editContent.raw.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateComment({ id: comment.id, contentType: editContent.mode, content: editContent.raw })
|
||||
setEditing(false)
|
||||
toast.success(t("supportPublic.toast.commentUpdated"))
|
||||
onChanged()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCurrentComment = async () => {
|
||||
if (!window.confirm(t("supportPublic.comment.deleteConfirm"))) return
|
||||
await deleteComment(comment.id)
|
||||
toast.success(t("supportPublic.toast.commentDeleted"))
|
||||
onChanged()
|
||||
}
|
||||
|
||||
const reportCurrentComment = async () => {
|
||||
await ensureSupportLogin()
|
||||
await reportComment(comment.id)
|
||||
toast.success(t("supportPublic.toast.commentReported"))
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${window.location.origin}${communityPostHref(post.id)}#comment-${comment.id}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success(t("supportPublic.toast.linkCopied"))
|
||||
}
|
||||
|
||||
const loadReplies = async () => {
|
||||
const result = await fetchComments({ postId: post.id, parentId: comment.id, page: 1, limit: 50 })
|
||||
setReplies(result.results)
|
||||
setRepliesExpanded(true)
|
||||
}
|
||||
|
||||
const actionButtonClass = "h-7 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`comment-${comment.id}`}
|
||||
className={cn(
|
||||
"group/comment scroll-mt-24 py-4",
|
||||
isReply && "py-2.5",
|
||||
comment.isAccepted && !isDeleted && !isReply && "border-l-2 border-emerald-500 pl-3"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex", isReply ? "gap-2.5" : "gap-3")}>
|
||||
<div className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full font-semibold",
|
||||
isReply ? "size-6 bg-muted text-xs text-muted-foreground" : "size-9 bg-primary text-sm text-primary-foreground"
|
||||
)}>
|
||||
{supportAuthorInitial(authorName)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className={cn("truncate font-medium", isReply ? "text-sm text-foreground/90" : "text-sm text-foreground")}>{authorName}</span>
|
||||
{isPostAuthor ? <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">{t("supportPublic.comment.authorBadge")}</span> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{formatDateTime(comment.createdAt)}</div>
|
||||
</div>
|
||||
{!isDeleted && comment.isAccepted && !isReply ? <Badge className="rounded-md bg-emerald-600 text-white shadow-none"><CheckCircle2Icon /> {t("supportPublic.comment.accepted")}</Badge> : null}
|
||||
</div>
|
||||
<div className={cn("mt-3", isReply && "mt-2 text-sm")}>
|
||||
{isDeleted ? (
|
||||
<div className="rounded-md bg-muted/70 px-3 py-2 text-sm text-muted-foreground">{t("supportPublic.comment.deleted")}</div>
|
||||
) : editing ? (
|
||||
<div className="rounded-md bg-muted/40 p-3">
|
||||
<ContentEditor value={editContent} onChange={setEditContent} disabled={submitting} allowedModes={["html", "markdown"]} height={220} className="min-w-0" />
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={submitting} onClick={() => setEditing(false)}>{t("supportPublic.actions.cancel")}</Button>
|
||||
<Button size="sm" disabled={submitting || !editContent.raw.trim()} onClick={() => void submitEdit()}>{t("supportPublic.actions.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PostArticleContent id={`support-comment-content-${comment.id}`} content={comment.content} contentType={comment.contentType} articleHeadingIds={false} />
|
||||
)}
|
||||
</div>
|
||||
{!isDeleted ? (
|
||||
<div className={cn("flex flex-wrap items-center gap-1", isReply ? "mt-3" : "mt-4")}>
|
||||
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => void ensureSupportLogin().then(() => toggleReaction({ targetType: "comment", targetId: comment.id })).then(onChanged)}>
|
||||
<ThumbsUpIcon /> {comment.reactionCount}
|
||||
</Button>
|
||||
{!isReply ? (
|
||||
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => setReplying((current) => !current)}>
|
||||
<CornerDownRightIcon /> {t("supportPublic.actions.reply")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => void copyLink()}>
|
||||
<CopyIcon /> {t("supportPublic.actions.copyLink")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => void reportCurrentComment()}>
|
||||
<FlagIcon /> {t("supportPublic.actions.report")}
|
||||
</Button>
|
||||
{isAuthor ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => setEditing(true)}>
|
||||
<PencilIcon /> {t("supportPublic.actions.edit")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 rounded-md px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => void deleteCurrentComment()}>
|
||||
<Trash2Icon /> {t("supportPublic.actions.delete")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{canAccept ? (
|
||||
<Button variant="secondary" size="sm" className="h-7 rounded-md px-2.5 text-xs text-primary" onClick={() => void ensureSupportLogin().then(() => acceptComment(post.id, comment.id)).then(onChanged)}>
|
||||
{t("supportPublic.actions.accept")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{replying ? (
|
||||
<div className="mt-3 rounded-md bg-muted/40 p-3">
|
||||
<ContentEditor value={replyContent} onChange={setReplyContent} placeholder={t("supportPublic.comment.replyPlaceholder")} disabled={submitting} allowedModes={["html", "markdown"]} height={180} className="min-w-0" />
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={submitting} onClick={() => setReplying(false)}>{t("supportPublic.actions.cancel")}</Button>
|
||||
<Button size="sm" disabled={submitting || !replyContent.raw.trim()} onClick={() => void submitReply()}>{t("supportPublic.actions.publishReply")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{replies.length ? (
|
||||
<div className="mt-3 divide-y divide-border/60 border-l border-border/70 pl-3 sm:pl-4">
|
||||
{replies.map((reply) => (
|
||||
<CommentItem key={reply.id} comment={reply} post={post} currentUserId={currentUserId} onChanged={onChanged} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!repliesExpanded && comment.replyCount > replies.length ? (
|
||||
<Button variant="ghost" size="sm" className="mt-2 h-7 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" onClick={() => void loadReplies()}>
|
||||
<ChevronDownIcon /> {t("supportPublic.actions.viewReplies", { count: comment.replyCount })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function supportAuthorInitial(name: string) {
|
||||
return name.trim().slice(0, 1).toUpperCase() || "U"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { ChevronDownIcon, EyeIcon, LoaderCircleIcon, MessageCircleMoreIcon, ThumbsUpIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { PublicArticleToc, hasArticleTocHeadings } from "@/app/(support)/support/_components/support-article-toc"
|
||||
import { CommunityFrame } from "@/app/(support)/support/_components/community-frame"
|
||||
import { PostArticleContent } from "@/app/(support)/support/_components/post-article-content"
|
||||
import { ensureSupportLogin, useCommunityCategoryRoute } from "@/app/(support)/support/_components/support-community-route"
|
||||
import { SupportEmptyState as EmptyState, PostStatusBadge as PostStatusBadge } from "@/app/(support)/support/_components/support-ui"
|
||||
import { CommentItem } from "@/app/(support)/support/community/posts/detail/_components/comment-item"
|
||||
import { PostMetric } from "@/app/(support)/support/community/posts/_components/post-ui"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { createComment, fetchComments, fetchPost, postsHref, toggleReaction, type Comment, type Post } from "@/lib/api/support-community"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { formatDateTime, cn } from "@/lib/utils"
|
||||
import type { ContentValue } from "@/components/content-editor"
|
||||
|
||||
type CommentSort = "default" | "latest" | "hot"
|
||||
|
||||
export function PostDetail() {
|
||||
const t = useI18n()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const categoryRoute = useCommunityCategoryRoute()
|
||||
const postId = useMemo(() => {
|
||||
const queryId = Number(searchParams.get("id"))
|
||||
if (queryId > 0) {
|
||||
return queryId
|
||||
}
|
||||
const pathId = Number(pathname.split("/").filter(Boolean).at(-1))
|
||||
return pathId > 0 ? pathId : 0
|
||||
}, [pathname, searchParams])
|
||||
const [post, setPost] = useState<Post | null>(null)
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [content, setContent] = useState<ContentValue>({ mode: "html", raw: "" })
|
||||
const [commentSort, setCommentSort] = useState<CommentSort>("default")
|
||||
const [commentPage, setCommentPage] = useState({ page: 1, limit: 20, total: 0 })
|
||||
const [commentsLoading, setCommentsLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const postArticleId = post ? `support-post-${post.id}` : ""
|
||||
const postToc = post && hasArticleTocHeadings(post.content, post.contentType)
|
||||
? <PublicArticleToc articleId={postArticleId} content={post.content} contentType={post.contentType} />
|
||||
: null
|
||||
const currentUserId = readSession()?.user.id ?? 0
|
||||
|
||||
const loadComments = useCallback((page = 1, append = false) => {
|
||||
if (postId > 0) {
|
||||
setCommentsLoading(true)
|
||||
void fetchComments({ postId, sort: commentSort, page, limit: commentPage.limit })
|
||||
.then((result) => {
|
||||
setComments((current) => append ? [...current, ...result.results] : result.results)
|
||||
setCommentPage(result.page)
|
||||
})
|
||||
.finally(() => setCommentsLoading(false))
|
||||
}
|
||||
}, [commentPage.limit, commentSort, postId])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (postId <= 0) return
|
||||
void Promise.all([
|
||||
fetchPost(postId),
|
||||
fetchComments({ postId, sort: commentSort, page: 1, limit: commentPage.limit }),
|
||||
]).then(([detail, commentResult]) => {
|
||||
setPost(detail.post)
|
||||
setComments(commentResult.results)
|
||||
setCommentPage(commentResult.page)
|
||||
})
|
||||
}, [commentPage.limit, commentSort, postId])
|
||||
|
||||
useEffect(() => {
|
||||
if (postId <= 0) return
|
||||
void fetchPost(postId).then((detail) => {
|
||||
setPost(detail.post)
|
||||
})
|
||||
}, [postId])
|
||||
|
||||
useEffect(() => {
|
||||
loadComments(1)
|
||||
}, [loadComments])
|
||||
|
||||
const submitComment = async () => {
|
||||
if (!post || submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await ensureSupportLogin()
|
||||
await createComment({ postId: post.id, contentType: content.mode, content: content.raw })
|
||||
setContent({ mode: "html", raw: "" })
|
||||
toast.success(t("supportPublic.toast.commentCreated"))
|
||||
reload()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
const hasMoreComments = comments.length < commentPage.total
|
||||
|
||||
return (
|
||||
<CommunityFrame active={post?.categoryId ?? "all"} categoryRoute={categoryRoute} toc={postToc}>
|
||||
<div className="px-4 py-7 sm:px-6 sm:py-10 lg:px-8 2xl:px-10">
|
||||
{post ? (
|
||||
<article className="w-full max-w-6xl">
|
||||
<Breadcrumb className="text-xs">
|
||||
<BreadcrumbList className="gap-y-1">
|
||||
<BreadcrumbItem>
|
||||
<Link href={postsHref()} className="transition-colors hover:text-foreground">{t("supportPublic.posts.title")}</Link>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{post.categoryName || t("supportPublic.common.uncategorized")}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<header className="mt-6">
|
||||
<h1 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">{post.title}</h1>
|
||||
</header>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<PostStatusBadge status={post.status} />
|
||||
<span>{post.categoryName || t("supportPublic.common.uncategorized")}</span>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<span>{t("supportPublic.posts.createdBy", { name: post.userName || t("supportPublic.common.user") })}</span>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<span>{t("supportPublic.posts.updatedAt", { date: formatDateTime(post.updatedAt || post.createdAt) })}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<PostArticleContent id={postArticleId} content={post.content} contentType={post.contentType} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="sm" className="rounded-md" onClick={() => void ensureSupportLogin().then(() => toggleReaction({ targetType: "post", targetId: post.id })).then(reload)}>
|
||||
<ThumbsUpIcon /> {post.reactionCount}
|
||||
</Button>
|
||||
<PostMetric icon={<MessageCircleMoreIcon className="size-3.5" />} value={post.commentCount} label={t("supportPublic.posts.comments")} />
|
||||
<PostMetric icon={<EyeIcon className="size-3.5" />} value={post.viewCount} label={t("supportPublic.posts.views")} />
|
||||
</div>
|
||||
|
||||
<section className="mt-8 border-t border-border/70 pt-6" aria-label={t("supportPublic.posts.comments")}>
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{t("supportPublic.posts.comments")}</h2>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{t("supportPublic.comment.count", { count: commentPage.total || post.commentCount })}</div>
|
||||
</div>
|
||||
<div className="inline-flex w-fit gap-1 rounded-md bg-muted p-0.5">
|
||||
{(["default", "latest", "hot"] as const).map((sort) => (
|
||||
<button
|
||||
key={sort}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
commentSort === sort ? "bg-background text-foreground shadow-xs" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
aria-pressed={commentSort === sort}
|
||||
onClick={() => setCommentSort(sort)}
|
||||
>
|
||||
{t(`supportPublic.comment.sort.${sort}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{comments.length ? (
|
||||
<div className="divide-y divide-border/70">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} post={post} currentUserId={currentUserId} onChanged={reload} />
|
||||
))}
|
||||
</div>
|
||||
) : commentsLoading ? (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
{t("supportPublic.loading.comments")}
|
||||
</div>
|
||||
) : <EmptyState text={t("supportPublic.empty.noComments")} />}
|
||||
{hasMoreComments ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="secondary" size="sm" className="rounded-md" disabled={commentsLoading} onClick={() => loadComments(commentPage.page + 1, true)}>
|
||||
{commentsLoading ? <LoaderCircleIcon className="animate-spin" /> : <ChevronDownIcon />}
|
||||
{commentsLoading ? t("supportPublic.loading.comments") : t("supportPublic.actions.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-6 border-t border-border/70 pt-5" aria-labelledby="support-comment-editor-title">
|
||||
<h2 id="support-comment-editor-title" className="text-base font-semibold">{t("supportPublic.comment.title")}</h2>
|
||||
<div className="mt-3 min-w-0">
|
||||
<ContentEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder={t("supportPublic.comment.placeholder")}
|
||||
disabled={submitting}
|
||||
allowedModes={["html", "markdown"]}
|
||||
height={260}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button disabled={submitting || !content.raw.trim()} onClick={() => void submitComment()}>
|
||||
{submitting ? t("supportPublic.actions.publishing") : t("supportPublic.actions.publishComment")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
) : (
|
||||
<div className="grid min-h-[60svh] place-items-center">
|
||||
<EmptyState text={t("supportPublic.loading.post")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommunityFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PostDetail } from "./_components/post-detail"
|
||||
|
||||
export default function PostDetailShellPage() {
|
||||
return <PostDetail />
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell"
|
||||
import { useSupportAuth } from "@/app/(support)/support/_components/support-auth-provider"
|
||||
import { ensureSupportLogin } from "@/app/(support)/support/_components/support-community-route"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { createPost, fetchCategories, postHref, type Category } from "@/lib/api/support-community"
|
||||
import type { ContentValue } from "@/components/content-editor"
|
||||
|
||||
export function CreatePost() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const { ready, session } = useSupportAuth()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [categoriesFailed, setCategoriesFailed] = useState(false)
|
||||
const [categoryId, setCategoryId] = useState(0)
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState<ContentValue>({ mode: "html", raw: "" })
|
||||
const [tags, setTags] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !session) {
|
||||
router.replace("/support/login?next=%2Fsupport%2Fcommunity%2Fposts%2Fnew")
|
||||
}
|
||||
}, [ready, router, session])
|
||||
|
||||
const loadCategories = useCallback(() => {
|
||||
setCategoriesLoading(true)
|
||||
setCategoriesFailed(false)
|
||||
void fetchCategories()
|
||||
.then((items) => {
|
||||
setCategories(items)
|
||||
setCategoryId((current) => current || items[0]?.id || 0)
|
||||
})
|
||||
.catch(() => {
|
||||
setCategories([])
|
||||
setCategoriesFailed(true)
|
||||
})
|
||||
.finally(() => setCategoriesLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(loadCategories, [loadCategories])
|
||||
|
||||
const handleContentChange = useCallback((next: ContentValue) => {
|
||||
if (content.mode === next.mode && content.raw === next.raw) {
|
||||
return
|
||||
}
|
||||
setContent(next)
|
||||
setFormError("")
|
||||
}, [content.mode, content.raw])
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return
|
||||
if (!categoryId) {
|
||||
setFormError(t("supportPublic.createPost.categoryRequired"))
|
||||
return
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setFormError(t("supportPublic.createPost.titleRequired"))
|
||||
return
|
||||
}
|
||||
if (!content.raw.trim()) {
|
||||
setFormError(t("supportPublic.createPost.contentRequired"))
|
||||
return
|
||||
}
|
||||
setFormError("")
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await ensureSupportLogin()
|
||||
const post = await createPost({
|
||||
categoryId,
|
||||
title,
|
||||
contentType: content.mode,
|
||||
content: content.raw,
|
||||
tags: tags.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
})
|
||||
toast.success(t("supportPublic.toast.postCreated"))
|
||||
window.location.assign(postHref(post.id))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("api.requestFailed")
|
||||
setFormError(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready || !session) {
|
||||
return (
|
||||
<SupportPageShell section="community">
|
||||
<SupportPageContent className="py-10 sm:py-12" width="docs">
|
||||
<div className="flex min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
{t("supportPublic.loading.session")}
|
||||
</div>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SupportPageShell section="community">
|
||||
<SupportPageContent className="py-8 sm:py-10" width="docs">
|
||||
<form
|
||||
className="w-full rounded-md bg-card p-4 sm:p-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 border-b pb-3">
|
||||
<h1 className="text-lg font-medium">{t("supportPublic.createPost.formTitle")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<fieldset aria-label={t("supportPublic.createPost.category")}>
|
||||
<legend className="sr-only">{t("supportPublic.createPost.category")}</legend>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={categoryId === item.id ? "default" : "secondary"}
|
||||
className="rounded-md"
|
||||
disabled={categoriesLoading || submitting}
|
||||
aria-pressed={categoryId === item.id}
|
||||
onClick={() => { setCategoryId(item.id); setFormError("") }}
|
||||
>
|
||||
{item.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{categoriesLoading ? <p className="mt-2 text-xs text-muted-foreground">{t("supportPublic.loading.categories")}</p> : null}
|
||||
{categoriesFailed ? <button type="button" className="mt-2 text-xs text-destructive underline-offset-4 hover:underline" onClick={loadCategories}>{t("supportPublic.createPost.categoriesFailed")}</button> : null}
|
||||
</fieldset>
|
||||
|
||||
<Input id="support-post-title" value={title} onChange={(event) => { setTitle(event.target.value); setFormError("") }} placeholder={t("supportPublic.createPost.postTitlePlaceholder")} className="rounded-md bg-card" disabled={submitting} aria-label={t("supportPublic.createPost.postTitle")} />
|
||||
|
||||
<div className="grid min-w-0 gap-2" role="group" aria-labelledby="support-post-content-label">
|
||||
<span id="support-post-content-label" className="sr-only">{t("supportPublic.createPost.content")}</span>
|
||||
<ContentEditor
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder={t("supportPublic.createPost.contentPlaceholder")}
|
||||
disabled={submitting}
|
||||
allowedModes={["html", "markdown"]}
|
||||
height={420}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input id="support-post-tags" value={tags} onChange={(event) => setTags(event.target.value)} placeholder={t("supportPublic.createPost.tagsPlaceholder")} className="rounded-md bg-card" disabled={submitting} aria-label={t("supportPublic.createPost.tags")} />
|
||||
|
||||
{formError ? <div role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{formError}</div> : null}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button type="submit" disabled={submitting || categoriesLoading || categoriesFailed}>
|
||||
{submitting ? t("supportPublic.actions.publishing") : t("supportPublic.actions.publishPost")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
5
web/app/(support)/support/community/posts/new/page.tsx
Normal file
5
web/app/(support)/support/community/posts/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CreatePost } from "./_components/create-post"
|
||||
|
||||
export default function SupportCreatePostPage() {
|
||||
return <CreatePost />
|
||||
}
|
||||
5
web/app/(support)/support/community/posts/page.tsx
Normal file
5
web/app/(support)/support/community/posts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PostList } from "./_components/post-list"
|
||||
|
||||
export default function SupportCommunityPostsPage() {
|
||||
return <PostList />
|
||||
}
|
||||
5
web/app/(support)/support/demo/page.tsx
Normal file
5
web/app/(support)/support/demo/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportWidgetDemo } from "@/components/support-chat/widget-demo"
|
||||
|
||||
export default function Page() {
|
||||
return <SupportWidgetDemo />
|
||||
}
|
||||
392
web/app/(support)/support/docs/_components/help-reader.tsx
Normal file
392
web/app/(support)/support/docs/_components/help-reader.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { ArrowRightIcon, ChevronDownIcon, ChevronRightIcon, FileTextIcon, FolderIcon, FolderOpenIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useImageLightboxOptional } from "@/components/image-lightbox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { SupportArticleContent } from "@/app/(support)/support/_components/support-article-content"
|
||||
import { PublicArticleToc } from "@/app/(support)/support/_components/support-article-toc"
|
||||
import { SupportHeader } from "@/app/(support)/support/_components/support-header"
|
||||
import { SupportDocLink, type DocPageNavigationHandler, useSupportDocRoute } from "@/app/(support)/support/_components/support-help-navigation"
|
||||
import { SupportEmptyState as EmptyState, SupportSearchInput } from "@/app/(support)/support/_components/support-ui"
|
||||
import { useSupportDocReader as useSupportDocReaderData } from "@/app/(support)/support/_components/use-support-help-reader"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchDocPages, submitDocPageFeedback, type DocPage } from "@/lib/api/support"
|
||||
import { articleHeadingId } from "@/lib/support-article"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
|
||||
export function SupportDocsList() {
|
||||
return <SupportDocsReader />
|
||||
}
|
||||
|
||||
export function DocPageDetail() {
|
||||
return <SupportDocsReader />
|
||||
}
|
||||
|
||||
function SupportDocsReader() {
|
||||
const t = useI18n()
|
||||
const pathname = usePathname()
|
||||
const [query, setQuery] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<DocPage[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const { activePath, navigate, replace } = useSupportDocRoute(pathname)
|
||||
const { page, pages, expanded, setExpanded, navigationLoading, pageLoading, failed } = useSupportDocReaderData(activePath, replace)
|
||||
const navigateToDocPage = useCallback<DocPageNavigationHandler>((event, targetPage) => {
|
||||
navigate(event, targetPage)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!page) return
|
||||
document.title = `${page.title} · ${t("supportPublic.help.title")}`
|
||||
}, [page, t])
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = query.trim()
|
||||
if (!keyword) return
|
||||
const timer = window.setTimeout(() => {
|
||||
void fetchDocPages({ keyword, limit: 50 })
|
||||
.then((result) => setSearchResults(result.results))
|
||||
.catch(() => setSearchResults([]))
|
||||
.finally(() => setSearchLoading(false))
|
||||
}, 250)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
const visiblePages = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
if (!keyword) return pages
|
||||
const matched = new Set<number>()
|
||||
pages.forEach((item) => {
|
||||
if (`${item.title} ${item.summary} ${item.slug} ${(item.tags || []).join(" ")}`.toLowerCase().includes(keyword)) {
|
||||
matched.add(item.id)
|
||||
let parentId = item.parentId
|
||||
while (parentId) {
|
||||
matched.add(parentId)
|
||||
parentId = pages.find((candidate) => candidate.id === parentId)?.parentId ?? 0
|
||||
}
|
||||
}
|
||||
})
|
||||
return pages.filter((item) => matched.has(item.id))
|
||||
}, [pages, query])
|
||||
|
||||
return (
|
||||
<SupportDocsFrame
|
||||
navigation={<DocNavigation pages={visiblePages} rootPages={visiblePages.filter((item) => !item.parentId)} searchResults={searchResults.map((item) => pages.find((candidate) => candidate.id === item.id) || item).filter((item) => Boolean(item.docPath))} title={query} expanded={expanded} selectedPageId={page?.id ?? 0} loading={navigationLoading || searchLoading} failed={failed} onTitleChange={(value) => { setQuery(value); setSearchResults([]); setSearchLoading(Boolean(value.trim())) }} onExpandedChange={setExpanded} onNavigate={navigateToDocPage} />}
|
||||
toc={<PublicArticleToc articleId="doc-page-detail-preview" content={page?.content ?? ""} contentType={page?.contentType} />}
|
||||
>
|
||||
<div aria-busy={pageLoading} className={cn(page && pageLoading && "opacity-60 transition-opacity")}>
|
||||
{page ? <DocArticle page={page} pages={pages} previewId="doc-page-detail-preview" onNavigate={navigateToDocPage} /> : <div className="grid min-h-[60svh] place-items-center"><EmptyState text={pageLoading ? t("supportPublic.loading.page") : failed ? t("supportPublic.empty.pageNotFound") : t("supportPublic.empty.noPages")} /></div>}
|
||||
</div>
|
||||
</SupportDocsFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportDocsFrame({
|
||||
children,
|
||||
navigation,
|
||||
toc,
|
||||
}: {
|
||||
children: ReactNode
|
||||
navigation: ReactNode
|
||||
toc: ReactNode
|
||||
}) {
|
||||
const t = useI18n()
|
||||
return (
|
||||
<main className="min-h-svh bg-background text-foreground">
|
||||
<SupportHeader
|
||||
section="help"
|
||||
mobileNavigation={navigation ? {
|
||||
title: t("supportPublic.help.navigation"),
|
||||
content: navigation,
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
<div className="support-docs-grid mx-auto max-w-[var(--support-docs-max-width)]">
|
||||
{navigation ? <aside className="hidden border-r xl:sticky xl:top-14 xl:block xl:h-[calc(100svh-3.5rem)] xl:overflow-y-auto">{navigation}</aside> : null}
|
||||
<div className="min-w-0 px-5 py-9 sm:px-6 sm:py-12 md:px-8 lg:px-10 2xl:px-12">{children}</div>
|
||||
{toc ? <div className="hidden 2xl:block">{toc}</div> : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function DocNavigation({
|
||||
pages,
|
||||
rootPages,
|
||||
searchResults = [],
|
||||
title,
|
||||
expanded,
|
||||
selectedPageId,
|
||||
loading,
|
||||
failed,
|
||||
onTitleChange,
|
||||
onExpandedChange,
|
||||
onNavigate,
|
||||
}: {
|
||||
pages: DocPage[]
|
||||
rootPages: DocPage[]
|
||||
searchResults?: DocPage[]
|
||||
title: string
|
||||
expanded: Set<number>
|
||||
selectedPageId: number
|
||||
loading: boolean
|
||||
failed: boolean
|
||||
onTitleChange: (value: string) => void
|
||||
onExpandedChange: (value: Set<number>) => void
|
||||
onNavigate: DocPageNavigationHandler
|
||||
}) {
|
||||
const t = useI18n()
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SupportSearchInput value={title} onChange={onTitleChange} placeholder={t("supportPublic.help.searchPlaceholder")} compact />
|
||||
<div className="mt-4 grid gap-0.5">
|
||||
{title.trim() ? searchResults.map((page) => (
|
||||
<SupportDocLink key={page.id} page={page} onNavigate={onNavigate} className={cn("rounded-md px-2.5 py-2 text-sm transition-colors hover:bg-muted", selectedPageId === page.id && "bg-primary/10 text-primary")}>
|
||||
<span className="block line-clamp-2 font-medium leading-5">{page.title}</span>
|
||||
{page.summary ? <span className="mt-1 block line-clamp-2 text-xs leading-5 text-muted-foreground">{page.summary}</span> : null}
|
||||
</SupportDocLink>
|
||||
)) : rootPages.map((page) => (
|
||||
<PublicDocPageNode key={page.id} page={page} depth={0} pages={pages} expanded={expanded} selectedPageId={selectedPageId} onToggle={(id) => {
|
||||
const next = new Set(expanded)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
onExpandedChange(next)
|
||||
}} onNavigate={onNavigate} />
|
||||
))}
|
||||
{loading ? <div className="px-2 py-8 text-center text-sm text-muted-foreground">{t("supportPublic.loading.navigation")}</div> : null}
|
||||
{!loading && (title.trim() ? !searchResults.length : !pages.length) ? <EmptyState text={failed ? t("supportPublic.empty.pagesFailed") : t("supportPublic.empty.noPagesMatched")} compact /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocArticle({ page, pages, previewId, onNavigate }: { page: DocPage; pages: DocPage[]; previewId: string; onNavigate: DocPageNavigationHandler }) {
|
||||
const t = useI18n()
|
||||
const lightbox = useImageLightboxOptional()
|
||||
const [feedbackPending, setFeedbackPending] = useState(false)
|
||||
const breadcrumbs = docPageBreadcrumbs(pages, page)
|
||||
const currentIndex = pages.findIndex((item) => item.id === page.id)
|
||||
const previousPage = currentIndex > 0 ? pages[currentIndex - 1] : null
|
||||
const nextPage = currentIndex >= 0 ? pages[currentIndex + 1] : null
|
||||
const submitFeedback = async (helpful: boolean) => {
|
||||
if (feedbackPending) return
|
||||
setFeedbackPending(true)
|
||||
try {
|
||||
await submitDocPageFeedback(page.id, helpful)
|
||||
toast.success(t("supportPublic.toast.feedbackSaved"))
|
||||
} finally {
|
||||
setFeedbackPending(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (page.contentType !== "html") return
|
||||
const container = document.getElementById(previewId)
|
||||
if (!container) return
|
||||
const cleanup: Array<() => void> = []
|
||||
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")
|
||||
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.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())
|
||||
}, [lightbox, page.content, page.contentType, previewId, t])
|
||||
return (
|
||||
<article className="mx-auto max-w-[var(--support-article-width)]">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="gap-y-1">
|
||||
<BreadcrumbItem>
|
||||
<Link href="/support/docs" className="transition-colors hover:text-foreground">{t("supportPublic.help.title")}</Link>
|
||||
</BreadcrumbItem>
|
||||
{breadcrumbs.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
{item.id === page.id ? (
|
||||
<BreadcrumbPage className="truncate">{item.title}</BreadcrumbPage>
|
||||
) : (
|
||||
<SupportDocLink page={item} onNavigate={onNavigate} className="truncate transition-colors hover:text-foreground">
|
||||
{item.title}
|
||||
</SupportDocLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="mt-6 text-balance text-3xl font-bold tracking-tight sm:text-4xl">{page.title}</h1>
|
||||
<div className="my-3 text-xs text-muted-foreground">{t("supportPublic.help.updatedAt", { date: formatDateTime(page.publishedAt || page.updatedAt) })}</div>
|
||||
<SupportArticleContent id={previewId} content={page.content} contentType={page.contentType} />
|
||||
<ChildPageLinks pages={pages.filter((item) => item.parentId === page.id)} onNavigate={onNavigate} />
|
||||
<div className="mt-12 flex flex-col gap-4 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t("supportPublic.help.feedbackTitle")}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{t("supportPublic.help.feedbackDescription")}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" disabled={feedbackPending} onClick={() => void submitFeedback(true)}><ThumbsUpIcon />{t("supportPublic.actions.helpful")}</Button>
|
||||
<Button variant="outline" disabled={feedbackPending} onClick={() => void submitFeedback(false)}><ThumbsDownIcon />{t("supportPublic.actions.notHelpful")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(previousPage || nextPage) ? <nav className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{previousPage ? <ArticlePager page={previousPage} direction="previous" onNavigate={onNavigate} /> : <span />}
|
||||
{nextPage ? <ArticlePager page={nextPage} direction="next" onNavigate={onNavigate} /> : null}
|
||||
</nav> : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function docPageBreadcrumbs(pages: DocPage[], page: DocPage) {
|
||||
const pagesById = new Map(pages.map((item) => [item.id, item]))
|
||||
const ancestors: DocPage[] = []
|
||||
const visited = new Set<number>([page.id])
|
||||
let parentId = page.parentId
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
const parent = pagesById.get(parentId)
|
||||
if (!parent) break
|
||||
ancestors.unshift(parent)
|
||||
visited.add(parent.id)
|
||||
parentId = parent.parentId
|
||||
}
|
||||
return [...ancestors, page]
|
||||
}
|
||||
|
||||
function ArticlePager({ page, direction, onNavigate }: { page: DocPage; direction: "previous" | "next"; onNavigate: DocPageNavigationHandler }) {
|
||||
const t = useI18n()
|
||||
return <SupportDocLink page={page} onNavigate={onNavigate} className={cn("group rounded-md border px-4 py-3 transition-colors hover:border-primary/40 hover:bg-muted/50", direction === "next" && "text-right")}>
|
||||
<span className="text-xs text-muted-foreground">{t(`supportPublic.help.${direction}`)}</span>
|
||||
<span className="mt-1 flex items-center justify-between gap-3 text-sm font-medium text-primary">{direction === "previous" ? <ChevronRightIcon className="size-4 rotate-180" /> : null}<span className={cn("truncate", direction === "next" && "ml-auto")}>{page.title}</span>{direction === "next" ? <ChevronRightIcon className="size-4" /> : null}</span>
|
||||
</SupportDocLink>
|
||||
}
|
||||
|
||||
function PublicDocPageNode({
|
||||
page,
|
||||
depth,
|
||||
pages,
|
||||
expanded,
|
||||
selectedPageId,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
}: {
|
||||
page: DocPage
|
||||
depth: number
|
||||
pages: DocPage[]
|
||||
expanded: Set<number>
|
||||
selectedPageId: number
|
||||
onToggle: (id: number) => void
|
||||
onNavigate: DocPageNavigationHandler
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const open = expanded.has(page.id)
|
||||
const children = pages.filter((item) => item.parentId === page.id)
|
||||
const hasChildren = children.length > 0
|
||||
const selected = selectedPageId === page.id
|
||||
return (
|
||||
<div className={cn(depth === 0 && "mt-1 first:mt-0")}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex min-h-9 w-full items-center rounded-md pr-2 text-sm text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground",
|
||||
depth === 0 && hasChildren && "font-semibold text-foreground",
|
||||
selected && "bg-primary/10 font-medium text-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-primary"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-background/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onToggle(page.id)}
|
||||
aria-expanded={open}
|
||||
aria-label={open ? t("supportPublic.a11y.collapse") : t("supportPublic.a11y.expand")}
|
||||
>
|
||||
{open ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
|
||||
</button>
|
||||
) : <span className="size-7 shrink-0" />}
|
||||
<SupportDocLink page={page} onNavigate={onNavigate} className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left leading-5" aria-current={selected ? "page" : undefined}>
|
||||
{hasChildren ? (open ? <FolderOpenIcon className="size-4 shrink-0" /> : <FolderIcon className="size-4 shrink-0" />) : <FileTextIcon className="size-3.5 shrink-0 opacity-70" />}
|
||||
<span className="line-clamp-2">{page.title}</span>
|
||||
</SupportDocLink>
|
||||
</div>
|
||||
{open && hasChildren ? (
|
||||
<div className="relative before:absolute before:inset-y-1 before:w-px before:bg-border/80" style={{ marginLeft: `${depth * 16 + 17}px` }}>
|
||||
<div style={{ marginLeft: `${-(depth * 16 + 17)}px` }}>
|
||||
{children.map((child) => (
|
||||
<PublicDocPageNode key={child.id} page={child} depth={depth + 1} pages={pages} expanded={expanded} selectedPageId={selectedPageId} onToggle={onToggle} onNavigate={onNavigate} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChildPageLinks({ pages, onNavigate }: { pages: DocPage[]; onNavigate: DocPageNavigationHandler }) {
|
||||
const t = useI18n()
|
||||
if (!pages.length) return null
|
||||
return (
|
||||
<section className="mt-10 border-t pt-6" aria-labelledby="support-child-pages-title">
|
||||
<h2 id="support-child-pages-title" className="mb-4 text-sm font-semibold tracking-tight text-foreground">
|
||||
{t("supportPublic.help.childPages")}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{pages.map((page) => (
|
||||
<SupportDocLink
|
||||
key={page.id}
|
||||
page={page}
|
||||
onNavigate={onNavigate}
|
||||
className="group flex min-w-0 items-start gap-3 rounded-md border border-border/70 bg-card p-4 shadow-xs transition-[border-color,background-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-primary/30 hover:bg-primary/[0.025] hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/60 text-muted-foreground transition-colors group-hover:border-primary/20 group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<FileTextIcon className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold leading-6 text-foreground transition-colors group-hover:text-primary">
|
||||
{page.title}
|
||||
</span>
|
||||
{page.summary ? <span className="mt-0.5 line-clamp-3 text-sm leading-5 text-muted-foreground">{page.summary}</span> : null}
|
||||
</span>
|
||||
<ArrowRightIcon className="mt-2 size-4 shrink-0 text-muted-foreground/60 transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-primary" aria-hidden="true" />
|
||||
</SupportDocLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
5
web/app/(support)/support/docs/page.tsx
Normal file
5
web/app/(support)/support/docs/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportDocsList } from "./_components/help-reader"
|
||||
|
||||
export default function DocPage() {
|
||||
return <SupportDocsList />
|
||||
}
|
||||
172
web/app/(support)/support/login/_components/login-page.tsx
Normal file
172
web/app/(support)/support/login/_components/login-page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Loader2Icon, TriangleAlertIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell"
|
||||
import { useSupportAuth } from "@/app/(support)/support/_components/support-auth-provider"
|
||||
import { SupportFormField as LabeledField } from "@/app/(support)/support/_components/support-ui"
|
||||
import { getSupportLoginDestination } from "@/app/(support)/support/_components/support-community-route"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config"
|
||||
import { loginSupportCustomer, registerSupportCustomer } from "@/lib/api/support"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function SupportLoginPage() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { ready, session } = useSupportAuth()
|
||||
const [mode, setMode] = useState<"login" | "register">("login")
|
||||
const [publicConfig, setPublicConfig] = useState<PublicConfig | null>(null)
|
||||
const [publicConfigError, setPublicConfigError] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [account, setAccount] = useState("")
|
||||
const [registerEmail, setRegisterEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const nextDestination = getSupportLoginDestination(searchParams.get("next"))
|
||||
const oidcError = searchParams.get("oidcError")
|
||||
// The server sends this back when enterprise SSO is started from the portal,
|
||||
// so a bookmarked or hand-crafted SSO link explains itself instead of
|
||||
// bouncing the visitor silently.
|
||||
const ssoRejectedError = oidcError
|
||||
const passwordLoginEnabled = publicConfig?.passwordLoginEnabled !== false
|
||||
// Enterprise SSO is deliberately not offered here. The server rejects it for a
|
||||
// portal destination, because it provisions a staff account that carries no
|
||||
// Customer behind it - a visitor would get an employee session with none of
|
||||
// their history. Showing a button that cannot work would be worse than not
|
||||
// showing it. Staff sign in at the dashboard login instead.
|
||||
const hasAnyLoginMethod = passwordLoginEnabled
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && session) router.replace(nextDestination)
|
||||
}, [nextDestination, ready, router, session])
|
||||
|
||||
useEffect(() => {
|
||||
if (!passwordLoginEnabled && mode === "register") setMode("login")
|
||||
}, [mode, passwordLoginEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (ssoRejectedError) toast.error(ssoRejectedError)
|
||||
}, [ssoRejectedError])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void fetchPublicConfig()
|
||||
.then((config) => {
|
||||
if (cancelled) return
|
||||
setPublicConfig(config)
|
||||
setPublicConfigError("")
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
setPublicConfig(null)
|
||||
setPublicConfigError(error instanceof Error ? error.message : t("api.requestFailed"))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting || !passwordLoginEnabled) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await (mode === "login"
|
||||
? loginSupportCustomer({ email: account, password })
|
||||
: registerSupportCustomer({ name, email: registerEmail, password }))
|
||||
toast.success(t("supportPublic.toast.loggedIn"))
|
||||
router.replace(nextDestination)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("auth.loginFailed"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SupportPageShell section="login">
|
||||
<SupportPageContent className="flex min-h-[calc(100svh-3.5rem)] items-start justify-center py-8 sm:items-center sm:py-12">
|
||||
<section className="w-full max-w-[440px] rounded-md bg-card px-5 py-6 sm:px-7 sm:py-8">
|
||||
<div className="mb-6 flex flex-col items-center gap-3 text-center">
|
||||
<Image
|
||||
src={publicConfig?.companyLogoUrl?.trim() || "/images/logo.svg"}
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
className="size-14 opacity-80"
|
||||
priority
|
||||
/>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("supportPublic.login.welcomeBack")}</h1>
|
||||
</div>
|
||||
{!publicConfig && !publicConfigError ? (
|
||||
<LoginState icon={<Loader2Icon className="size-5 animate-spin" />} title={t("auth.loadingOptions")} />
|
||||
) : null}
|
||||
{publicConfigError ? (
|
||||
<LoginState icon={<TriangleAlertIcon className="size-5" />} title={t("auth.optionsLoadFailed")} description={publicConfigError} destructive />
|
||||
) : null}
|
||||
{publicConfig && !hasAnyLoginMethod ? (
|
||||
<LoginState icon={<TriangleAlertIcon className="size-5" />} title={t("supportPublic.login.noMethodsTitle")} description={t("supportPublic.login.noMethodsDescription")} />
|
||||
) : null}
|
||||
{publicConfig && hasAnyLoginMethod ? (
|
||||
<div className="grid gap-5">
|
||||
{passwordLoginEnabled ? (
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
{mode === "register" ? (
|
||||
<LabeledField label={t("supportPublic.login.name")}>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder={t("supportPublic.login.namePlaceholder")} className="bg-card" />
|
||||
</LabeledField>
|
||||
) : null}
|
||||
<LabeledField label={mode === "login" ? t("supportPublic.login.account") : t("supportPublic.login.email")}>
|
||||
<Input
|
||||
value={mode === "login" ? account : registerEmail}
|
||||
onChange={(event) => (mode === "login" ? setAccount(event.target.value) : setRegisterEmail(event.target.value))}
|
||||
placeholder={mode === "login" ? t("supportPublic.login.accountPlaceholder") : t("supportPublic.login.emailPlaceholder")}
|
||||
autoComplete={mode === "login" ? "username" : "email"}
|
||||
className="bg-card"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("supportPublic.login.password")}>
|
||||
<Input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder={t("supportPublic.login.passwordPlaceholder")} autoComplete={mode === "login" ? "current-password" : "new-password"} className="bg-card" />
|
||||
</LabeledField>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? t("supportPublic.actions.processing") : mode === "login" ? t("supportPublic.login.loginAction") : t("supportPublic.login.registerAction")}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
|
||||
{mode === "login" ? t("supportPublic.login.switchToRegister") : t("supportPublic.login.switchToLogin")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginState({ icon, title, description, destructive }: { icon: ReactNode; title: string; description?: string; destructive?: boolean }) {
|
||||
return (
|
||||
<div className={cn("flex min-h-64 flex-col items-center justify-center gap-3 text-center", destructive ? "text-destructive" : "text-muted-foreground")}>
|
||||
{icon}
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
web/app/(support)/support/login/page.tsx
Normal file
5
web/app/(support)/support/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportLoginPage } from "./_components/login-page"
|
||||
|
||||
export default function SupportCustomerLoginPage() {
|
||||
return <SupportLoginPage />
|
||||
}
|
||||
110
web/app/(support)/support/page-client.tsx
Normal file
110
web/app/(support)/support/page-client.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowRightIcon, BookOpenIcon, CircleHelpIcon, HeadphonesIcon } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell"
|
||||
import { SupportSearchInput } from "@/app/(support)/support/_components/support-ui"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { postsHref } from "@/lib/api/support-community"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function SupportCenterHome() {
|
||||
const t = useI18n()
|
||||
const [query, setQuery] = useState("")
|
||||
|
||||
return (
|
||||
<SupportPageShell>
|
||||
<section className="relative border-y border-sky-100 bg-[radial-gradient(circle_at_50%_-30%,#ddecff,transparent_55%)] px-5 py-12 sm:px-8 sm:py-18 dark:border-border dark:bg-[radial-gradient(circle_at_50%_-30%,rgba(36,117,252,.26),transparent_55%)]">
|
||||
<div className="relative mx-auto max-w-3xl text-center">
|
||||
<Badge variant="secondary" className="mb-5 bg-white/70 px-3 py-1 text-primary dark:bg-card/80">
|
||||
{t("supportPublic.home.badge")}
|
||||
</Badge>
|
||||
<h1 className="text-balance text-3xl font-semibold tracking-tight sm:text-5xl">
|
||||
{t("supportPublic.home.title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl text-pretty text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
{t("supportPublic.home.description")}
|
||||
</p>
|
||||
<div className="relative mx-auto mt-8 flex max-w-2xl flex-col gap-2 sm:flex-row">
|
||||
<SupportSearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("supportPublic.home.searchPlaceholder")}
|
||||
hero
|
||||
/>
|
||||
<Link
|
||||
className={cn(buttonVariants({ size: "lg" }), "h-13 rounded-md px-6")}
|
||||
href={`${postsHref()}${query ? `?title=${encodeURIComponent(query)}` : ""}`}
|
||||
>
|
||||
{t("supportPublic.actions.search")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SupportPageContent className="py-10 sm:py-14">
|
||||
<section className="grid gap-3 sm:grid-cols-3" aria-label={t("supportPublic.nav.siteNavigation")}>
|
||||
<SupportEntryCard
|
||||
href="/support/docs"
|
||||
icon={<BookOpenIcon />}
|
||||
title={t("supportPublic.home.helpTitle")}
|
||||
description={t("supportPublic.home.helpDescription")}
|
||||
accent="sky"
|
||||
/>
|
||||
<SupportEntryCard
|
||||
href={postsHref()}
|
||||
icon={<CircleHelpIcon />}
|
||||
title={t("supportPublic.home.postsTitle")}
|
||||
description={t("supportPublic.home.postsDescription")}
|
||||
accent="violet"
|
||||
/>
|
||||
<SupportEntryCard
|
||||
href="/support/chat"
|
||||
icon={<HeadphonesIcon />}
|
||||
title={t("supportPublic.home.chatTitle")}
|
||||
description={t("supportPublic.home.chatDescription")}
|
||||
accent="emerald"
|
||||
/>
|
||||
</section>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportEntryCard({
|
||||
href,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
accent,
|
||||
}: {
|
||||
href: string
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
accent: "sky" | "violet" | "emerald"
|
||||
}) {
|
||||
const accentClass = {
|
||||
sky: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
violet: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
emerald: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
}[accent]
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group rounded-md bg-card p-5 text-left transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className={cn("grid size-10 place-items-center rounded-md [&_svg]:size-5", accentClass)}>{icon}</span>
|
||||
<ArrowRightIcon className="mt-1 size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
|
||||
</div>
|
||||
<h3 className="mt-5 font-medium">{title}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
5
web/app/(support)/support/page.tsx
Normal file
5
web/app/(support)/support/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportCenterHome } from "./page-client"
|
||||
|
||||
export default function SupportPage() {
|
||||
return <SupportCenterHome />
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, type ReactNode } from "react"
|
||||
import { MailIcon, PencilIcon, UserRoundIcon } from "lucide-react"
|
||||
|
||||
import { useSupportAuth } from "@/app/(support)/support/_components/support-auth-provider"
|
||||
import { CommunityPostList } from "@/app/(support)/support/_components/community-post-list"
|
||||
import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell"
|
||||
import { PostListLoading } from "@/app/(support)/support/community/posts/_components/post-ui"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function SupportProfilePage() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const { ready, session } = useSupportAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !session) {
|
||||
router.replace("/support/login?next=/support/profile")
|
||||
}
|
||||
}, [ready, router, session])
|
||||
|
||||
if (!ready || !session) {
|
||||
return (
|
||||
<SupportPageShell section="community">
|
||||
<SupportPageContent width="docs" className="py-8">
|
||||
<PostListLoading />
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const user = session.user
|
||||
const displayName = user.nickname || user.username
|
||||
const fallback = displayName.slice(0, 1).toUpperCase() || "U"
|
||||
|
||||
return (
|
||||
<SupportPageShell section="community">
|
||||
<SupportPageContent width="docs" className="py-6 sm:py-8">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
|
||||
<aside className="h-fit rounded-md bg-card p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-14">
|
||||
<AvatarImage src={user.avatar} alt={displayName} />
|
||||
<AvatarFallback>{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-semibold">{displayName}</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-5 grid gap-3 text-sm">
|
||||
<ProfileMeta icon={<UserRoundIcon className="size-4" />} label={t("supportPublic.profile.username")} value={user.username} />
|
||||
<ProfileMeta icon={<MailIcon className="size-4" />} label={t("supportPublic.profile.email")} value={user.email || t("supportPublic.profile.emailUnset")} />
|
||||
</dl>
|
||||
<Link className={cn(buttonVariants(), "mt-5 w-full")} href="/support/profile/edit">
|
||||
<PencilIcon />
|
||||
{t("supportPublic.profile.edit")}
|
||||
</Link>
|
||||
</aside>
|
||||
<section className="min-w-0 rounded-md bg-card">
|
||||
<div className="border-b px-5 py-4">
|
||||
<h2 className="text-base font-semibold">{t("supportPublic.profile.communityTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("supportPublic.profile.communityDescription")}</p>
|
||||
</div>
|
||||
<div className="px-5 py-3">
|
||||
<CommunityPostList
|
||||
emptyText={t("supportPublic.profile.noPosts")}
|
||||
limit={10}
|
||||
query={{ userId: user.id }}
|
||||
resetKey={`profile:${user.id}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileMeta({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md bg-muted/45 px-3 py-2">
|
||||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
<dt className="shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 flex-1 truncate text-right font-medium">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useSupportAuth } from "@/app/(support)/support/_components/support-auth-provider"
|
||||
import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell"
|
||||
import { SupportFormField } from "@/app/(support)/support/_components/support-ui"
|
||||
import { ImageInput } from "@/components/image-input"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { updateProfile, uploadProfileAvatar } from "@/lib/api/auth"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function SupportProfileEditPage() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const { ready, session, refreshSession } = useSupportAuth()
|
||||
const [nickname, setNickname] = useState("")
|
||||
const [avatar, setAvatar] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !session) {
|
||||
router.replace("/support/login?next=/support/profile/edit")
|
||||
}
|
||||
}, [ready, router, session])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
setNickname(session.user.nickname || session.user.username)
|
||||
setAvatar(session.user.avatar || "")
|
||||
setEmail(session.user.email || "")
|
||||
}, [session])
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return
|
||||
const nextNickname = nickname.trim()
|
||||
if (!nextNickname) {
|
||||
setError(t("supportPublic.profile.nicknameRequired"))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError("")
|
||||
try {
|
||||
await updateProfile({
|
||||
avatar: avatar.trim(),
|
||||
email: email.trim() || undefined,
|
||||
nickname: nextNickname,
|
||||
})
|
||||
await refreshSession()
|
||||
toast.success(t("supportPublic.toast.profileUpdated"))
|
||||
router.replace("/support/profile")
|
||||
} catch (err) {
|
||||
setError((err as Error).message || t("api.requestFailed"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SupportPageShell section="community">
|
||||
<SupportPageContent className="py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-2xl rounded-md bg-card">
|
||||
<div className="border-b px-5 py-4 sm:px-6">
|
||||
<h1 className="text-lg font-semibold">{t("supportPublic.profile.editTitle")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("supportPublic.profile.editDescription")}</p>
|
||||
</div>
|
||||
<div className="grid gap-5 p-5 sm:p-6">
|
||||
<SupportFormField label={t("supportPublic.profile.avatar")}>
|
||||
<ImageInput value={avatar} onChange={setAvatar} upload={uploadProfileAvatar} placeholder={t("supportPublic.profile.avatarPlaceholder")} />
|
||||
</SupportFormField>
|
||||
<SupportFormField label={t("supportPublic.profile.nickname")}>
|
||||
<Input value={nickname} onChange={(event) => setNickname(event.target.value)} placeholder={t("supportPublic.profile.nicknamePlaceholder")} />
|
||||
</SupportFormField>
|
||||
<SupportFormField label={t("supportPublic.profile.email")}>
|
||||
<Input value={email} onChange={(event) => setEmail(event.target.value)} placeholder={t("supportPublic.profile.emailPlaceholder")} />
|
||||
</SupportFormField>
|
||||
{error ? <p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end sm:px-6">
|
||||
<Link className={cn(buttonVariants({ variant: "outline" }), "sm:w-auto")} href="/support/profile">
|
||||
{t("supportPublic.actions.cancel")}
|
||||
</Link>
|
||||
<Button disabled={submitting} onClick={() => void submit()}>
|
||||
{submitting ? t("supportPublic.actions.processing") : t("supportPublic.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
5
web/app/(support)/support/profile/edit/page.tsx
Normal file
5
web/app/(support)/support/profile/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportProfileEditPage } from "./_components/profile-edit-page"
|
||||
|
||||
export default function ProfileEditPage() {
|
||||
return <SupportProfileEditPage />
|
||||
}
|
||||
5
web/app/(support)/support/profile/page.tsx
Normal file
5
web/app/(support)/support/profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportProfilePage } from "./_components/profile-page"
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <SupportProfilePage />
|
||||
}
|
||||
297
web/app/(support)/typeset.css
Normal file
297
web/app/(support)/typeset.css
Normal file
@@ -0,0 +1,297 @@
|
||||
@layer components {
|
||||
.typeset {
|
||||
--typeset-font-body: var(--font-app-sans);
|
||||
--typeset-font-heading: var(--font-app-sans);
|
||||
--typeset-font-mono: var(--font-app-mono);
|
||||
--typeset-size: 1rem;
|
||||
--typeset-leading: 1.8;
|
||||
--typeset-flow: 1.4em;
|
||||
|
||||
color: var(--foreground);
|
||||
font-family: var(--typeset-font-body), sans-serif;
|
||||
font-size: var(--typeset-size);
|
||||
line-height: var(--typeset-leading);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.typeset > :where(:not(.not-typeset, [data-not-typeset])) {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.typeset > :where(:not(:first-child, .not-typeset, [data-not-typeset])) {
|
||||
margin-block-start: var(--typeset-flow);
|
||||
}
|
||||
|
||||
.typeset :where(h1, h2, h3, h4, h5, h6):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
color: var(--foreground);
|
||||
font-family: var(--typeset-font-heading), sans-serif;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.typeset :where(h1):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.typeset :where(h2):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
margin-block-start: calc(var(--typeset-flow) * 1.8);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.55em;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.typeset :where(h3):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
margin-block-start: calc(var(--typeset-flow) * 1.45);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.typeset :where(h4):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.typeset :where(a):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: color-mix(in oklab, var(--primary) 35%, transparent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.typeset :where(a:hover):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.typeset :where(strong):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
color: var(--foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.typeset :where(ul, ol):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
padding-inline-start: 1.6em;
|
||||
}
|
||||
|
||||
.typeset :where(ul):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.typeset :where(ol):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.typeset :where(li + li):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
margin-block-start: 0.35em;
|
||||
}
|
||||
|
||||
.typeset :where(li > ul, li > ol):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
margin-block-start: 0.35em;
|
||||
}
|
||||
|
||||
.typeset :where(blockquote):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
border-inline-start: 3px solid var(--primary);
|
||||
color: var(--muted-foreground);
|
||||
padding-inline-start: 1em;
|
||||
}
|
||||
|
||||
.typeset :where(code):not(:where(pre code, .not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--muted);
|
||||
padding: 0.12em 0.35em;
|
||||
font-family: var(--typeset-font-mono), monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.typeset :where(pre):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--muted);
|
||||
padding: 1em 5.5rem 1em 1.1em;
|
||||
color: var(--foreground);
|
||||
font-family: var(--typeset-font-mono), monospace;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.typeset :where(pre code):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.typeset-support-docs {
|
||||
--syntax-comment: oklch(0.52 0.02 255);
|
||||
--syntax-keyword: oklch(0.48 0.19 292);
|
||||
--syntax-string: oklch(0.46 0.13 145);
|
||||
--syntax-number: oklch(0.5 0.16 52);
|
||||
--syntax-title: oklch(0.45 0.17 255);
|
||||
--syntax-variable: oklch(0.48 0.17 22);
|
||||
--syntax-meta: oklch(0.48 0.12 210);
|
||||
--syntax-addition: oklch(0.43 0.13 145);
|
||||
--syntax-deletion: oklch(0.5 0.18 22);
|
||||
}
|
||||
|
||||
.dark .typeset-support-docs {
|
||||
--syntax-comment: oklch(0.68 0.02 255);
|
||||
--syntax-keyword: oklch(0.76 0.14 304);
|
||||
--syntax-string: oklch(0.78 0.12 145);
|
||||
--syntax-number: oklch(0.8 0.13 75);
|
||||
--syntax-title: oklch(0.78 0.12 245);
|
||||
--syntax-variable: oklch(0.78 0.13 30);
|
||||
--syntax-meta: oklch(0.77 0.1 210);
|
||||
--syntax-addition: oklch(0.78 0.12 145);
|
||||
--syntax-deletion: oklch(0.76 0.15 25);
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-comment, .hljs-quote) {
|
||||
color: var(--syntax-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-keyword, .hljs-selector-tag, .hljs-subst) {
|
||||
color: var(--syntax-keyword);
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-string, .hljs-regexp, .hljs-doctag) {
|
||||
color: var(--syntax-string);
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-number, .hljs-literal) {
|
||||
color: var(--syntax-number);
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-title, .hljs-section, .hljs-selector-id, .hljs-selector-class) {
|
||||
color: var(--syntax-title);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-variable, .hljs-template-variable, .hljs-attr, .hljs-attribute) {
|
||||
color: var(--syntax-variable);
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-built_in, .hljs-type, .hljs-class .hljs-title, .hljs-symbol, .hljs-bullet) {
|
||||
color: var(--syntax-meta);
|
||||
}
|
||||
|
||||
.typeset-support-docs :where(.hljs-meta, .hljs-meta .hljs-keyword) {
|
||||
color: var(--syntax-meta);
|
||||
}
|
||||
|
||||
.typeset-support-docs .hljs-addition {
|
||||
color: var(--syntax-addition);
|
||||
}
|
||||
|
||||
.typeset-support-docs .hljs-deletion {
|
||||
color: var(--syntax-deletion);
|
||||
}
|
||||
|
||||
.support-mermaid {
|
||||
margin-block-start: var(--typeset-flow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in oklab, var(--muted) 45%, var(--background));
|
||||
}
|
||||
|
||||
.support-mermaid-canvas {
|
||||
min-height: 10rem;
|
||||
overflow: auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.support-mermaid-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.support-mermaid-preview[data-support-lightbox-item] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.support-mermaid-preview:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.support-mermaid-canvas > svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 28rem;
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.support-mermaid-status {
|
||||
display: grid;
|
||||
min-height: 7.5rem;
|
||||
place-items: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.support-mermaid-error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.typeset :where(hr):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.typeset :where(img):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: min(70vh, 720px);
|
||||
border-radius: 0.75rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.typeset-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.typeset :where(table):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.925em;
|
||||
}
|
||||
|
||||
.typeset :where(th, td):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.65em 0.8em;
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.typeset :where(th):not(:where(.not-typeset, .not-typeset *, [data-not-typeset], [data-not-typeset] *)) {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.typeset-support-docs > h1:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.typeset-support-docs pre > button[data-not-typeset] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 40rem) {
|
||||
.typeset-support-docs :where(pre) {
|
||||
padding: 3rem 1.1em 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user