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

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

View File

@@ -0,0 +1,608 @@
"use client"
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
type CollisionDetection,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
ChevronRightIcon,
ExternalLinkIcon,
EyeOffIcon,
FilePlus2Icon,
GripVerticalIcon,
MoreHorizontalIcon,
RocketIcon,
SaveIcon,
SearchIcon,
Settings2Icon,
Trash2Icon,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
import { toast } from "sonner"
import { useApiErrorHandler } from "@/components/api-error-provider"
import { useConfirm } from "@/components/confirm-provider"
import { ContentEditor } from "@/components/content-editor"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "@/components/ui/context-menu"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { useI18n } from "@/i18n/provider"
import { normalizeSupportSlug } from "@/lib/support-slug"
import {
changeDocPageStatusAdmin,
deleteDocPageAdmin,
fetchDocPageAdmin,
fetchDocPagesAllAdmin,
saveDocPageAdmin,
updateDocPageSettingsAdmin,
updateDocPageSortAdmin,
uploadAsset,
type AdminDocPage,
} from "@/lib/api/admin"
import { cn } from "@/lib/utils"
type PageDraft = Pick<AdminDocPage, "parentId" | "title" | "slug" | "summary" | "content" | "contentType" | "tags">
type PageSettingsDraft = Pick<PageDraft, "parentId" | "slug" | "summary">
type CreateState = { open: boolean; parentId: number }
const blankCreate: CreateState = { open: false, parentId: 0 }
function toDraft(page: AdminDocPage): PageDraft {
return {
parentId: page.parentId ?? 0,
title: page.title,
slug: page.slug,
summary: page.summary ?? "",
content: page.content ?? "",
contentType: page.contentType || "markdown",
tags: page.tags ?? [],
}
}
function descendantIds(pages: AdminDocPage[], id: number) {
const result = new Set<number>()
const visit = (parentId: number) => pages.filter((page) => page.parentId === parentId).forEach((page) => {
result.add(page.id)
visit(page.id)
})
visit(id)
return result
}
function childPages(pages: AdminDocPage[], parentId: number) {
return pages
.filter((page) => page.parentId === parentId)
.sort((left, right) => left.sortNo - right.sortNo || left.id - right.id)
}
function reorderSiblings(pages: AdminDocPage[], parentId: number, activeId: number, overId: number) {
const siblings = childPages(pages, parentId)
const oldIndex = siblings.findIndex((page) => page.id === activeId)
const newIndex = siblings.findIndex((page) => page.id === overId)
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null
const ordered = arrayMove(siblings, oldIndex, newIndex)
const sortByID = new Map(ordered.map((page, index) => [page.id, index]))
return {
ids: ordered.map((page) => page.id),
pages: pages.map((page) => page.parentId === parentId ? { ...page, sortNo: sortByID.get(page.id) ?? page.sortNo } : page),
}
}
function pagePath(pages: AdminDocPage[], page: AdminDocPage | null) {
if (!page) return []
const path: Array<Pick<AdminDocPage, "id" | "title">> = []
let parentId = page.parentId
while (parentId) {
const parent = pages.find((item) => item.id === parentId)
if (!parent) break
path.unshift({ id: parent.id, title: parent.title })
parentId = parent.parentId
}
return path
}
export function SupportDocsWorkbench() {
const t = useI18n()
const handleApiError = useApiErrorHandler()
const confirm = useConfirm()
const [pages, setPages] = useState<AdminDocPage[]>([])
const [selected, setSelected] = useState<AdminDocPage | null>(null)
const [draft, setDraft] = useState<PageDraft | null>(null)
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [query, setQuery] = useState("")
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [sorting, setSorting] = useState(false)
const [activeDragId, setActiveDragId] = useState<number | null>(null)
const [createState, setCreateState] = useState<CreateState>(blankCreate)
const [settingsOpen, setSettingsOpen] = useState(false)
const selectionRequest = useRef(0)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const dirty = Boolean(selected && draft && JSON.stringify(toDraft(selected)) !== JSON.stringify(draft))
const sortingDisabled = Boolean(query.trim()) || loading || sorting
const activeDragPage = activeDragId ? pages.find((page) => page.id === activeDragId) : undefined
const siblingCollisionDetection = useCallback<CollisionDetection>((args) => {
const activePage = pages.find((page) => page.id === Number(args.active.id))
if (!activePage) return []
const siblingIDs = new Set(childPages(pages, activePage.parentId).map((page) => page.id))
return closestCenter({
...args,
droppableContainers: args.droppableContainers.filter((container) => siblingIDs.has(Number(container.id))),
})
}, [pages])
const statusOptions = useMemo(() => [
{ value: "draft", label: t("supportHelpWorkbench.statusDraft") },
{ value: "published", label: t("supportHelpWorkbench.statusPublished") },
{ value: "hidden", label: t("supportHelpWorkbench.statusHidden") },
], [t])
const load = useCallback(async (preferredId?: number) => {
setLoading(true)
try {
const result = await fetchDocPagesAllAdmin()
setPages(result)
const requestedId = preferredId ?? selected?.id
const id = requestedId && result.some((page) => page.id === requestedId)
? requestedId
: result[0]?.id
if (!id) {
setSelected(null)
setDraft(null)
return
}
const detail = await fetchDocPageAdmin(id)
setSelected(detail)
setDraft(toDraft(detail))
const ancestors = new Set<number>()
let parentId = detail.parentId
while (parentId) {
ancestors.add(parentId)
parentId = result.find((page) => page.id === parentId)?.parentId ?? 0
}
setExpanded((current) => new Set([...current, ...ancestors]))
} catch (error) {
handleApiError(error)
} finally {
setLoading(false)
}
}, [handleApiError, selected?.id])
useEffect(() => { void load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault()
void save()
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
})
async function selectPage(page: AdminDocPage) {
if (page.id === selected?.id) return
if (dirty) {
const leave = await confirm({
title: t("supportHelpWorkbench.unsavedLeaveTitle"),
description: t("supportHelpWorkbench.unsavedLeaveConfirm"),
confirmText: t("supportHelpWorkbench.discardChanges"),
cancelText: t("supportHelpWorkbench.continueEditing"),
variant: "destructive",
})
if (!leave) return
}
const requestId = ++selectionRequest.current
try {
const detail = await fetchDocPageAdmin(page.id)
if (requestId !== selectionRequest.current) return
setSelected(detail)
setDraft(toDraft(detail))
} catch (error) {
if (requestId === selectionRequest.current) handleApiError(error)
}
}
async function save() {
if (!selected || !draft || !draft.title.trim() || !draft.slug.trim()) return
setSaving(true)
try {
const saved = await saveDocPageAdmin({
id: selected.id,
...draft,
status: selected.status,
sortNo: draft.parentId === selected.parentId
? pages.find((page) => page.id === selected.id)?.sortNo ?? selected.sortNo
: childPages(pages, draft.parentId).length,
title: draft.title.trim(),
slug: draft.slug.trim(),
summary: draft.summary.trim(),
})
setSelected(saved)
setDraft(toDraft(saved))
setPages((items) => items.map((item) => item.id === saved.id ? saved : item))
toast.success(t("supportHelpWorkbench.saved"))
} catch (error) {
handleApiError(error)
} finally {
setSaving(false)
}
}
async function saveSettings(settings: PageSettingsDraft) {
if (!selected || !draft || !settings.slug.trim() || saving) return
setSaving(true)
try {
const saved = await updateDocPageSettingsAdmin({
id: selected.id,
parentId: settings.parentId,
slug: settings.slug.trim(),
summary: settings.summary.trim(),
})
setSelected(saved)
setDraft({ ...draft, parentId: saved.parentId, slug: saved.slug, summary: saved.summary })
setPages((items) => items.map((item) => item.id === saved.id ? saved : item))
setSettingsOpen(false)
toast.success(t("supportHelpWorkbench.settingsSaved"))
} catch (error) {
handleApiError(error)
} finally {
setSaving(false)
}
}
async function changeStatus(status: "draft" | "published" | "hidden") {
if (!selected || saving) return
if (dirty) {
toast.info(t("supportHelpWorkbench.saveBeforeStatusChange"))
return
}
const confirmed = await confirm({
title: t(`supportHelpWorkbench.${status}ConfirmTitle`, { title: selected.title }),
description: t(`supportHelpWorkbench.${status}ConfirmDescription`),
confirmText: t(`supportHelpWorkbench.${status}Action`),
cancelText: t("supportHelpWorkbench.cancel"),
variant: status === "hidden" ? "destructive" : "default",
})
if (!confirmed) return
setSaving(true)
try {
const changed = await changeDocPageStatusAdmin(selected.id, status)
setSelected(changed)
setDraft(toDraft(changed))
setPages((items) => items.map((item) => item.id === changed.id ? changed : item))
toast.success(t(`supportHelpWorkbench.${status}Success`))
} catch (error) {
handleApiError(error)
} finally {
setSaving(false)
}
}
async function remove(page: AdminDocPage) {
const confirmed = await confirm({
title: t("supportHelpWorkbench.deleteConfirmTitle", { title: page.title }),
description: t("supportHelpWorkbench.deleteConfirm", { title: page.title }),
confirmText: t("supportHelpWorkbench.deletePage"),
cancelText: t("supportHelpWorkbench.cancel"),
variant: "destructive",
})
if (!confirmed) return
try {
await deleteDocPageAdmin(page.id)
selectionRequest.current += 1
toast.success(t("supportHelpWorkbench.deleted"))
await load(selected?.id === page.id ? undefined : selected?.id)
} catch (error) {
handleApiError(error)
}
}
async function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over || active.id === over.id || sortingDisabled) return
const activePage = pages.find((page) => page.id === Number(active.id))
const overPage = pages.find((page) => page.id === Number(over.id))
if (!activePage || !overPage) return
if (activePage.parentId !== overPage.parentId) {
toast.info(t("supportHelpWorkbench.sameLevelOnly"))
return
}
const reordered = reorderSiblings(pages, activePage.parentId, activePage.id, overPage.id)
if (!reordered) return
const previous = pages
setPages(reordered.pages)
setSorting(true)
try {
await updateDocPageSortAdmin({ parentId: activePage.parentId, ids: reordered.ids })
toast.success(t("supportHelpWorkbench.sortSaved"))
} catch (error) {
setPages(previous)
handleApiError(error)
} finally {
setSorting(false)
}
}
const visibleIds = useMemo(() => {
const keyword = query.trim().toLowerCase()
if (!keyword) return null
const ids = new Set<number>()
for (const page of pages) {
if (keyword && !`${page.title} ${page.slug} ${page.summary}`.toLowerCase().includes(keyword)) continue
ids.add(page.id)
let parentId = page.parentId
while (parentId) {
ids.add(parentId)
parentId = pages.find((item) => item.id === parentId)?.parentId ?? 0
}
}
return ids
}, [pages, query])
const roots = childPages(pages, 0).filter((page) => !visibleIds || visibleIds.has(page.id))
const path = pagePath(pages, selected)
const statusLabel = statusOptions.find((item) => item.value === selected?.status)?.label ?? selected?.status
return (
<div className="flex min-h-0 flex-1 overflow-hidden bg-background">
<aside className="flex w-72 shrink-0 flex-col border-r bg-muted/20">
<div className="flex h-14 items-center justify-between border-b px-3">
<span className="font-semibold">{t("supportHelpWorkbench.title")}</span>
<Button size="icon" variant="ghost" title={t("supportHelpWorkbench.createPage")} aria-label={t("supportHelpWorkbench.createPage")} onClick={() => setCreateState({ open: true, parentId: 0 })}>
<FilePlus2Icon />
</Button>
</div>
<div className="p-3">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} className="pl-9" placeholder={t("supportHelpWorkbench.searchPlaceholder")} />
</div>
{query.trim() ? <p className="mt-2 text-xs text-muted-foreground">{t("supportHelpWorkbench.sortDisabledDuringSearch")}</p> : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
<DndContext
sensors={sensors}
collisionDetection={siblingCollisionDetection}
onDragStart={(event) => setActiveDragId(Number(event.active.id))}
onDragCancel={() => setActiveDragId(null)}
onDragEnd={(event) => void handleDragEnd(event)}
>
<SortableContext items={roots.map((page) => page.id)} strategy={verticalListSortingStrategy}>
{roots.map((page) => (
<PageTreeNode
key={page.id}
page={page}
pages={pages}
depth={0}
selectedId={selected?.id}
expanded={expanded}
forceExpanded={Boolean(query)}
visibleIds={visibleIds}
sortingDisabled={sortingDisabled}
onToggle={(id) => setExpanded((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})}
onSelect={selectPage}
onCreate={(parentId) => setCreateState({ open: true, parentId })}
onDelete={remove}
/>
))}
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeDragPage ? <PageDragOverlay page={activeDragPage} /> : null}
</DragOverlay>
</DndContext>
{!loading && pages.length === 0 ? <div className="px-3 py-10 text-center text-sm text-muted-foreground">{t("supportHelpWorkbench.empty")}</div> : null}
{loading ? <div className="px-3 py-10 text-center text-sm text-muted-foreground">{t("supportHelpWorkbench.loading")}</div> : null}
</div>
</aside>
<main key={selected?.id ?? "empty"} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{draft && selected ? <>
<div className="sticky top-0 z-20 flex h-14 items-center justify-between gap-4 border-b bg-background px-4">
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
{path.map((item, index) => <span key={item.id} className="flex min-w-0 items-center gap-2">{index > 0 ? <ChevronRightIcon className="size-3.5 shrink-0" /> : null}<span className="truncate">{item.title}</span></span>)}
{path.length > 0 ? <ChevronRightIcon className="size-3.5 shrink-0" /> : null}
<Input
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
className="h-8 min-w-32 max-w-xl flex-1 border-border/60 bg-muted/30 px-2 text-sm font-medium text-foreground shadow-none hover:border-border hover:bg-muted/50 focus-visible:bg-background"
placeholder={t("supportHelpWorkbench.pageTitle")}
aria-label={t("supportHelpWorkbench.pageTitle")}
/>
<Badge variant="outline" className="ml-1 shrink-0">{statusLabel}</Badge>
{selected.status === "published" && dirty ? <span className="hidden text-xs text-amber-600 xl:inline dark:text-amber-400">{t("supportHelpWorkbench.publishedSaveWarning")}</span> : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={cn("hidden text-xs sm:inline", dirty ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")}>{dirty ? t("supportHelpWorkbench.unsaved") : t("supportHelpWorkbench.savedState")}</span>
<Button size="icon" variant="ghost" title={t("supportHelpWorkbench.pageSettings")} aria-label={t("supportHelpWorkbench.pageSettings")} onClick={() => setSettingsOpen(true)}><Settings2Icon /></Button>
{selected.status === "published" ? <Button size="icon" variant="ghost" nativeButton={false} title={t("supportHelpWorkbench.openPublicPage")} aria-label={t("supportHelpWorkbench.openPublicPage")} render={<a href={`/support/docs/${selected.slug}`} target="_blank" rel="noreferrer" />}><ExternalLinkIcon /></Button> : null}
<Button variant={selected.status === "published" ? "default" : "outline"} onClick={() => void save()} disabled={!dirty || saving} title={t("supportHelpWorkbench.save")}><SaveIcon /><span className="hidden xl:inline">{saving ? t("supportHelpWorkbench.saving") : t("supportHelpWorkbench.save")}</span></Button>
{selected.status === "published" ? <Button variant="outline" onClick={() => void changeStatus("draft")} disabled={saving} title={t("supportHelpWorkbench.withdrawAction")}><EyeOffIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.withdrawAction")}</span></Button> : selected.status !== "hidden" ? <Button variant="outline" onClick={() => void changeStatus("hidden")} disabled={saving} title={t("supportHelpWorkbench.hiddenAction")}><EyeOffIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.hiddenAction")}</span></Button> : null}
{selected.status !== "published" ? <Button onClick={() => void changeStatus("published")} disabled={saving || dirty} title={t("supportHelpWorkbench.publishedAction")}><RocketIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.publishedAction")}</span></Button> : null}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col bg-card">
<div className="flex min-h-0 flex-1 flex-col">
<ContentEditor
value={{ mode: draft.contentType === "html" ? "html" : "markdown", raw: draft.content }}
onChange={(content) => setDraft({ ...draft, content: content.raw, contentType: content.mode })}
onUploadImage={async (file) => {
const asset = await uploadAsset(file, "support/docs")
return { url: asset.url, alt: asset.filename, title: asset.filename }
}}
placeholder={t("supportHelpWorkbench.contentPlaceholder")}
height="100%"
className="min-h-0 flex-1 [&>div]:h-full [&>div]:rounded-none [&>div]:border-0 [&>div]:bg-transparent"
/>
</div>
</div>
</> : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("supportHelpWorkbench.selectPrompt")}</div>}
</main>
<CreatePageDialog key={`${createState.open}-${createState.parentId}`} state={createState} pages={pages} onOpenChange={(open) => setCreateState((current) => ({ ...current, open }))} onCreated={async (id) => { setCreateState(blankCreate); await load(id) }} />
<PageSettingsDialog key={`${selected?.id ?? 0}-${settingsOpen}`} open={settingsOpen} onOpenChange={setSettingsOpen} pages={pages} selected={selected} draft={draft} saving={saving} onSave={saveSettings} />
</div>
)
}
type PageTreeNodeProps = {
page: AdminDocPage
pages: AdminDocPage[]
depth: number
selectedId?: number
expanded: Set<number>
forceExpanded: boolean
visibleIds: Set<number> | null
sortingDisabled: boolean
onToggle: (id: number) => void
onSelect: (page: AdminDocPage) => void
onCreate: (parentId: number) => void
onDelete: (page: AdminDocPage) => void
}
function PageTreeNode(props: PageTreeNodeProps) {
const { page, pages, depth, selectedId, expanded, forceExpanded, visibleIds, sortingDisabled, onToggle, onSelect, onCreate, onDelete } = props
const t = useI18n()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: page.id, disabled: sortingDisabled })
const children = childPages(pages, page.id).filter((item) => !visibleIds || visibleIds.has(item.id))
const open = forceExpanded || expanded.has(page.id)
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition: isDragging ? undefined : transition }
return <div ref={setNodeRef} style={style} className={cn(isDragging && "relative z-10 opacity-30")}>
<ContextMenu>
<ContextMenuTrigger className="block">
<div className={cn("group flex h-9 items-center rounded-md pr-1 text-sm", selectedId === page.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/60", isDragging && "shadow-sm")} style={{ paddingLeft: 4 + depth * 16 }}>
<button type="button" className="flex size-7 shrink-0 items-center justify-center" onClick={() => children.length && onToggle(page.id)} aria-label={open ? t("supportHelpWorkbench.collapse") : t("supportHelpWorkbench.expand")}>
<ChevronRightIcon className={cn("size-4 transition-transform", !children.length && "opacity-0", open && "rotate-90")} />
</button>
<button type="button" className="min-w-0 flex-1 truncate text-left" onClick={() => void onSelect(page)}>{page.title}</button>
<span className={cn("mr-1 size-1.5 rounded-full", page.status === "published" ? "bg-emerald-500" : page.status === "hidden" ? "bg-amber-500" : "bg-muted-foreground/40")} />
<button type="button" className="flex size-7 shrink-0 cursor-grab items-center justify-center text-muted-foreground touch-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-35" disabled={sortingDisabled} aria-label={t("supportHelpWorkbench.dragPage", { title: page.title })} title={sortingDisabled ? t("supportHelpWorkbench.dragDisabled") : t("supportHelpWorkbench.dragPage", { title: page.title })} {...attributes} {...listeners}>
<GripVerticalIcon className="size-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger render={<Button size="icon-sm" variant="ghost" className="opacity-0 group-hover:opacity-100 focus:opacity-100" aria-label={t("supportHelpWorkbench.pageActions", { title: page.title })} />}><MoreHorizontalIcon /></DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onCreate(page.id)}><FilePlus2Icon />{t("supportHelpWorkbench.createChildPage")}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => void onDelete(page)}><Trash2Icon />{t("supportHelpWorkbench.deletePage")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => onCreate(page.id)}><FilePlus2Icon />{t("supportHelpWorkbench.createChildPage")}</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={() => void onDelete(page)}><Trash2Icon />{t("supportHelpWorkbench.deletePage")}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{open ? <SortableContext items={children.map((child) => child.id)} strategy={verticalListSortingStrategy}>
{children.map((child) => <PageTreeNode key={child.id} {...props} page={child} depth={depth + 1} />)}
</SortableContext> : null}
</div>
}
function PageDragOverlay({ page }: { page: AdminDocPage }) {
return <div className="flex h-9 w-64 items-center rounded-md border bg-background px-3 text-sm shadow-lg">
<GripVerticalIcon className="mr-2 size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{page.title}</span>
</div>
}
function CreatePageDialog({ state, pages, onOpenChange, onCreated }: { state: CreateState; pages: AdminDocPage[]; onOpenChange: (open: boolean) => void; onCreated: (id: number) => void }) {
const t = useI18n()
const handleApiError = useApiErrorHandler()
const [title, setTitle] = useState("")
const [slug, setSlug] = useState("")
const [parentId, setParentId] = useState(state.parentId)
const [creating, setCreating] = useState(false)
async function create() {
if (!title.trim() || !slug.trim() || creating) return
setCreating(true)
try {
const page = await saveDocPageAdmin({
parentId,
title: title.trim(),
slug: slug.trim(),
summary: "",
contentType: "markdown",
content: "",
tags: [],
status: "draft",
sortNo: childPages(pages, parentId).length,
})
toast.success(t("supportHelpWorkbench.created"))
onCreated(page.id)
} catch (error) {
handleApiError(error)
} finally {
setCreating(false)
}
}
return <Dialog open={state.open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>{t("supportHelpWorkbench.createPage")}</DialogTitle><DialogDescription>{t("supportHelpWorkbench.createDescription")}</DialogDescription></DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.pageTitle")}</Label><Input autoFocus value={title} onChange={(event) => { setTitle(event.target.value); if (!slug) setSlug(normalizeSupportSlug(event.target.value)) }} /></div>
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.slug")}</Label><Input value={slug} onChange={(event) => setSlug(normalizeSupportSlug(event.target.value))} placeholder="getting-started" /><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugFormatHint")}</p></div>
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.parentPage")}</Label><OptionCombobox value={String(parentId)} onChange={(value) => setParentId(Number(value))} placeholder={t("supportHelpWorkbench.selectParentPage")} options={[{ value: "0", label: t("supportHelpWorkbench.rootDirectory") }, ...pages.map((page) => ({ value: String(page.id), label: page.title }))]} /></div>
</div>
<DialogFooter><Button variant="outline" disabled={creating} onClick={() => onOpenChange(false)}>{t("supportHelpWorkbench.cancel")}</Button><Button disabled={!title.trim() || !slug.trim() || creating} onClick={() => void create()}>{creating ? t("supportHelpWorkbench.creating") : t("supportHelpWorkbench.createAndEdit")}</Button></DialogFooter>
</DialogContent>
</Dialog>
}
function PageSettingsDialog({ open, onOpenChange, pages, selected, draft, saving, onSave }: { open: boolean; onOpenChange: (open: boolean) => void; pages: AdminDocPage[]; selected: AdminDocPage | null; draft: PageDraft | null; saving: boolean; onSave: (settings: PageSettingsDraft) => void }) {
const t = useI18n()
const [settings, setSettings] = useState<PageSettingsDraft>(() => ({ parentId: draft?.parentId ?? 0, slug: draft?.slug ?? "", summary: draft?.summary ?? "" }))
if (!draft || !selected) return null
const excluded = descendantIds(pages, selected.id)
excluded.add(selected.id)
const options = [{ value: "0", label: t("supportHelpWorkbench.rootDirectory") }, ...pages.filter((page) => !excluded.has(page.id)).map((page) => ({ value: String(page.id), label: page.title }))]
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>{t("supportHelpWorkbench.pageSettings")}</DialogTitle></DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.parentPage")}</Label><OptionCombobox value={String(settings.parentId)} onChange={(value) => setSettings({ ...settings, parentId: Number(value) })} placeholder={t("supportHelpWorkbench.selectParentPage")} options={options} /></div>
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.slug")}</Label><Input value={settings.slug} onChange={(event) => setSettings({ ...settings, slug: normalizeSupportSlug(event.target.value) })} /><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugFormatHint")}</p><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugChangeWarning")}</p></div>
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.summary")}</Label><Textarea value={settings.summary} onChange={(event) => setSettings({ ...settings, summary: event.target.value })} rows={4} placeholder={t("supportHelpWorkbench.summaryPlaceholder")} /></div>
</div>
<DialogFooter><Button variant="outline" disabled={saving} onClick={() => onOpenChange(false)}>{t("supportHelpWorkbench.cancel")}</Button><Button disabled={!settings.slug.trim() || saving} onClick={() => onSave(settings)}>{saving ? t("supportHelpWorkbench.saving") : t("supportHelpWorkbench.save")}</Button></DialogFooter>
</DialogContent>
</Dialog>
}

View File

@@ -0,0 +1,210 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { MessageSquareTextIcon } from "lucide-react"
import { toast } from "sonner"
import { DashboardCrudPage } from "@/components/dashboard/crud"
import { DashboardPage } from "@/components/dashboard-page"
import { OptionCombobox } from "@/components/option-combobox"
import { ProjectDialog } from "@/components/project-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import {
acceptCommunityCommentAdmin,
createCommunityCommentAdmin,
deleteCommunityCategoryAdmin,
fetchCommunityPostAdmin,
fetchCommunityCategoriesAllAdmin,
fetchCommunityCategoriesAdmin,
fetchCommunityPostsAdmin,
moderateCommunityPostAdmin,
saveCommunityCategoryAdmin,
updateCommunityCategorySortAdmin,
type AdminCategory,
type AdminPost,
type AdminPostDetail,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
import { normalizeSupportSlug, supportSlugPattern } from "@/lib/support-slug"
function getPostStatusOptions(t: (key: string) => string) {
return [
{ value: "all", label: t("supportCommunityAdmin.allStatuses") },
{ value: "pending", label: t("supportCommunityAdmin.statusPending") },
{ value: "normal", label: t("supportCommunityAdmin.statusNormal") },
{ value: "resolved", label: t("supportCommunityAdmin.statusResolved") },
{ value: "closed", label: t("supportCommunityAdmin.statusClosed") },
{ value: "hidden", label: t("supportCommunityAdmin.statusHidden") },
]
}
type CategoryPayload = Pick<
AdminCategory,
"name" | "slug" | "description" | "status" | "remark"
>
function postStatusLabel(status: string, t: (key: string) => string) {
return getPostStatusOptions(t).find((item) => item.value === status)?.label ?? status
}
export function DashboardSupportCommunityCategoryAdmin() {
const t = useI18n()
const categoryStatusOptions = [
{ value: "all", label: t("supportCommunityCategory.allStatuses") },
{ value: "0", label: t("supportCommunityCategory.enabled") },
{ value: "1", label: t("supportCommunityCategory.disabled") },
]
const crudFormLabels = {
create: t("supportCommunityCategory.create"),
save: t("supportCommunityCategory.save"),
saving: t("supportCommunityCategory.saving"),
cancel: t("supportCommunityCategory.cancel"),
loadingDetail: t("supportCommunityCategory.loadingDetail"),
required: t("supportCommunityCategory.required"),
invalidNumber: t("supportCommunityCategory.invalidNumber"),
minValue: (min: number) => t("supportCommunityCategory.minValue", { min }),
maxValue: (max: number) => t("supportCommunityCategory.maxValue", { max }),
}
return (
<DashboardCrudPage<AdminCategory, CategoryPayload>
filters={[
{ name: "name", label: t("supportCommunityCategory.name"), placeholder: t("supportCommunityCategory.searchName"), defaultValue: "", trim: true, className: "w-full sm:w-72" },
{ name: "status", label: t("supportCommunityCategory.allStatuses"), placeholder: t("supportCommunityCategory.allStatuses"), type: "select", defaultValue: "all", allValue: "all", valueType: "number", options: categoryStatusOptions, className: "w-full sm:w-36" },
]}
columns={[
{ key: "name", label: t("supportCommunityCategory.name"), render: (item) => <div><div className="font-medium">{item.name}</div><div className="mt-1 text-sm text-muted-foreground">{item.description || t("supportCommunityCategory.noDescription")}</div></div> },
{ key: "slug", label: "Slug", className: "w-48", render: (item) => <span className="font-mono text-sm">{item.slug}</span> },
{ key: "status", label: t("supportCommunityCategory.status"), className: "w-28", render: (item) => <Badge variant={item.status === 0 ? "default" : "outline"}>{item.status === 0 ? t("supportCommunityCategory.enabled") : t("supportCommunityCategory.disabled")}</Badge> },
]}
fetchList={fetchCommunityCategoriesAdmin}
getItemId={(item) => item.id}
createItem={(payload) => saveCommunityCategoryAdmin(payload)}
updateItem={(item, payload) => saveCommunityCategoryAdmin({ id: item.id, ...payload })}
deleteItem={(item) => deleteCommunityCategoryAdmin(item.id)}
deleteConfirm={(item) => ({ title: t("supportCommunityCategory.confirmDeleteTitle"), description: t("supportCommunityCategory.confirmDeleteDescription", { name: item.name }), confirmText: t("supportCommunityCategory.delete"), variant: "destructive" })}
sort={{
enabled: true,
onReorder: (items) => updateCommunityCategorySortAdmin(items.map((item) => item.id)),
successMessage: t("supportCommunityCategory.sortUpdated"),
errorMessage: t("supportCommunityCategory.sortUpdateFailed"),
handleLabel: t("supportCommunityCategory.dragSort", { name: "" }),
}}
form={{
fields: [
{ name: "name", label: t("supportCommunityCategory.name"), placeholder: t("supportCommunityCategory.namePlaceholder"), required: true, trim: true },
{ name: "slug", label: "Slug", placeholder: t("supportCommunityCategory.slugPlaceholder"), required: true, trim: true, normalizeInput: normalizeSupportSlug, pattern: supportSlugPattern, patternMessage: t("supportCommunityCategory.slugPatternMessage") },
{ name: "description", label: t("supportCommunityCategory.description"), placeholder: t("supportCommunityCategory.descriptionPlaceholder"), type: "textarea", rows: 3, trim: true },
{ name: "status", label: t("supportCommunityCategory.status"), type: "select", defaultValue: "0", valueType: "number", required: true, options: categoryStatusOptions.filter((item) => item.value !== "all"), valueFromItem: (item) => String(item.status) },
{ name: "remark", label: t("supportCommunityCategory.remark"), placeholder: t("supportCommunityCategory.remarkPlaceholder"), type: "textarea", rows: 3, trim: true },
],
transformSubmitValues: (values) => ({ name: String(values.name), slug: normalizeSupportSlug(String(values.slug)), description: String(values.description ?? ""), status: Number(values.status), remark: String(values.remark ?? "") }),
labels: { ...crudFormLabels, createTitle: t("supportCommunityCategory.createTitle"), editTitle: t("supportCommunityCategory.editTitle") },
}}
labels={{
refresh: t("supportCommunityCategory.refresh"), create: t("supportCommunityCategory.new"), query: t("supportCommunityCategory.query"), loading: t("supportCommunityCategory.loading"), empty: t("supportCommunityCategory.empty"), actions: t("supportCommunityCategory.actions"), edit: t("supportCommunityCategory.edit"), delete: t("supportCommunityCategory.delete"), processing: t("supportCommunityCategory.processing"), moreActions: (item) => t("supportCommunityCategory.moreActions", { name: item.name }), loadFailed: t("supportCommunityCategory.loadFailed"), saveFailed: t("supportCommunityCategory.saveFailed"), deleteFailed: t("supportCommunityCategory.deleteFailed"), created: (payload) => t("supportCommunityCategory.created", { name: payload.name }), updated: (item) => t("supportCommunityCategory.updated", { name: item.name }), deleted: (item) => t("supportCommunityCategory.deleted", { name: item.name }),
}}
/>
)
}
export function DashboardSupportCommunityAdmin() {
const t = useI18n()
const [categories, setCategories] = useState<AdminCategory[]>([])
const [posts, setPosts] = useState<AdminPost[]>([])
const [categoryId, setCategoryId] = useState<number | "all">("all")
const [status, setStatus] = useState("all")
const [detail, setDetail] = useState<AdminPostDetail | null>(null)
const [loading, setLoading] = useState(false)
const [comment, setComment] = useState("")
const reloadPosts = useCallback(async () => {
setLoading(true)
try {
const page = await fetchCommunityPostsAdmin({ categoryId: categoryId === "all" ? undefined : categoryId, status: status === "all" ? undefined : status, limit: 50 })
setPosts(page.results)
} finally {
setLoading(false)
}
}, [categoryId, status])
useEffect(() => { void fetchCommunityCategoriesAllAdmin().then(setCategories) }, [])
useEffect(() => { void reloadPosts() }, [reloadPosts])
const openPost = async (id: number) => setDetail(await fetchCommunityPostAdmin(id))
const refreshDetail = async () => { if (detail) setDetail(await fetchCommunityPostAdmin(detail.post.id)) }
const moderate = async (nextStatus: string) => {
if (!detail) return
await moderateCommunityPostAdmin(detail.post.id, nextStatus)
toast.success(t("supportCommunityAdmin.postStatusUpdated"))
await Promise.all([refreshDetail(), reloadPosts()])
}
const postStatusOptions = getPostStatusOptions(t)
const reply = async () => {
if (!detail || !comment.trim()) return
await createCommunityCommentAdmin(detail.post.id, comment.trim())
setComment("")
toast.success(t("supportCommunityAdmin.commentSuccess"))
await Promise.all([refreshDetail(), reloadPosts()])
}
return (
<DashboardPage>
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
<div className="w-full sm:w-44">
<OptionCombobox
value={String(categoryId)}
onChange={(value) => setCategoryId(value === "all" ? "all" : Number(value))}
placeholder={t("supportCommunityAdmin.allCategories")}
options={[{ value: "all", label: t("supportCommunityAdmin.allCategories") }, ...categories.map((item) => ({ value: String(item.id), label: item.name }))]}
/>
</div>
<div className="w-full sm:w-36">
<OptionCombobox
value={status}
onChange={setStatus}
placeholder={t("supportCommunityAdmin.allStatuses")}
options={postStatusOptions}
/>
</div>
<Button variant="outline" onClick={() => void reloadPosts()} disabled={loading}>{t("supportCommunityAdmin.refresh")}</Button>
</div>
<div className="overflow-hidden rounded-md border">
{posts.map((post) => (
<button key={post.id} type="button" className="flex w-full items-center gap-4 border-b px-4 py-3 text-left last:border-b-0 hover:bg-muted/50" onClick={() => void openPost(post.id)}>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted"><MessageSquareTextIcon className="size-4 text-muted-foreground" /></span>
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{post.title}</span><span className="mt-1 block text-sm text-muted-foreground">{post.userName || t("supportCommunityAdmin.defaultUser")} · {post.categoryName || t("supportCommunityAdmin.uncategorized")} · {formatDateTime(post.createdAt)}</span></span>
<span className="hidden text-sm text-muted-foreground sm:block">{t("supportCommunityAdmin.commentCount", { count: post.commentCount })}</span>
<Badge variant={post.status === "resolved" ? "default" : "outline"}>{postStatusLabel(post.status, t)}</Badge>
</button>
))}
{!loading && posts.length === 0 ? <div className="py-16 text-center text-sm text-muted-foreground">{t("supportCommunityAdmin.noMatchingPosts")}</div> : null}
{loading ? <div className="py-16 text-center text-sm text-muted-foreground">{t("supportCommunityAdmin.loadingPosts")}</div> : null}
</div>
<ProjectDialog open={Boolean(detail)} onOpenChange={(open) => { if (!open) setDetail(null) }} title={detail?.post.title ?? t("supportCommunityAdmin.moderateDialogTitle")} description={detail ? `${detail.post.userName || t("supportCommunityAdmin.defaultUser")} · ${detail.post.categoryName || t("supportCommunityAdmin.uncategorized")} · ${formatDateTime(detail.post.createdAt)}` : undefined} size="xl" allowFullscreen>
{detail ? (
<div className="grid gap-5">
<div className="flex flex-wrap items-center gap-2"><Badge variant={detail.post.status === "resolved" ? "default" : "outline"}>{postStatusLabel(detail.post.status, t)}</Badge><span className="text-sm text-muted-foreground">{t("supportCommunityAdmin.likesAndViews", { likes: detail.post.reactionCount, views: detail.post.viewCount })}</span></div>
<p className="whitespace-pre-wrap text-sm leading-7">{detail.post.content}</p>
<div className="flex flex-wrap gap-2 border-y py-3">
<Button variant="outline" onClick={() => void moderate("normal")}>{t("supportCommunityAdmin.restoreNormalAction")}</Button>
<Button variant="outline" onClick={() => void moderate("closed")}>{t("supportCommunityAdmin.closePostAction")}</Button>
<Button variant="destructive" onClick={() => void moderate("hidden")}>{t("supportCommunityAdmin.hidePostAction")}</Button>
</div>
<Card><CardHeader><CardTitle className="text-base">{t("supportCommunityAdmin.commentsTitle", { count: detail.comments.length })}</CardTitle></CardHeader><CardContent className="grid gap-3">{detail.comments.map((item) => <div key={item.id} className="rounded-md border p-3"><div className="flex items-center justify-between gap-3"><span className="text-sm font-medium">{item.authorName || item.authorType}</span>{item.isAccepted ? <Badge>{t("supportCommunityAdmin.acceptedBadge")}</Badge> : <Button size="sm" variant="outline" onClick={() => void acceptCommunityCommentAdmin(detail.post.id, item.id).then(refreshDetail)}>{t("supportCommunityAdmin.acceptAnswerAction")}</Button>}</div><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{item.content}</p></div>)}</CardContent></Card>
<div className="grid gap-2"><Textarea value={comment} onChange={(event) => setComment(event.target.value)} rows={5} placeholder={t("supportCommunityAdmin.commentPlaceholder")} /><div className="flex justify-end"><Button onClick={() => void reply()} disabled={!comment.trim()}>{t("supportCommunityAdmin.publishCommentAction")}</Button></div></div>
</div>
) : null}
</ProjectDialog>
</DashboardPage>
)
}

View File

@@ -0,0 +1,385 @@
"use client"
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { ExternalLinkIcon, GripVerticalIcon, PlusIcon, RefreshCwIcon, SaveIcon, Trash2Icon } from "lucide-react"
import { toast } from "sonner"
import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar } from "@/components/dashboard-page"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useI18n } from "@/i18n/provider"
import {
fetchSupportConfigAdmin,
saveSupportConfigAdmin,
type SupportNavigationMenuItem,
} from "@/lib/api/admin"
import { isApiRequestError } from "@/lib/api/client"
import { cn } from "@/lib/utils"
type ConfigFieldError = {
path: string
code: string
message: string
}
type NavigationMenuRowProps = {
item: SupportNavigationMenuItem
disabled: boolean
canDelete: boolean
onChange: (id: string, values: Partial<SupportNavigationMenuItem>) => void
onDelete: (id: string) => void
}
const newMenuItem = (): SupportNavigationMenuItem => ({
id: `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: "",
url: "/support",
openInNewWindow: false,
visible: true,
sortNo: 0,
})
function normalizeRows(rows: SupportNavigationMenuItem[]) {
return rows.map((item, index) => ({ ...item, sortNo: (index + 1) * 10 }))
}
function serializeRows(rows: SupportNavigationMenuItem[]) {
return JSON.stringify(
rows.map(({ id, title, url, openInNewWindow, visible }) => ({
id,
title: title.trim(),
url: url.trim(),
openInNewWindow,
visible,
}))
)
}
export function SupportConfigPanel() {
const t = useI18n()
const [items, setItems] = useState<SupportNavigationMenuItem[]>([])
const [savedSnapshot, setSavedSnapshot] = useState("")
const [fieldErrors, setFieldErrors] = useState<ConfigFieldError[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const dirty = useMemo(() => serializeRows(items) !== savedSnapshot, [items, savedSnapshot])
const canDelete = items.length > 1
const loadConfig = useCallback(async () => {
try {
setLoading(true)
const config = await fetchSupportConfigAdmin()
const nextItems = normalizeRows(config.navigationMenu)
setItems(nextItems)
setSavedSnapshot(serializeRows(nextItems))
setFieldErrors([])
} catch (error) {
toast.error(error instanceof Error ? error.message : t("supportConfig.loadFailed"))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
void loadConfig()
}, [loadConfig])
useEffect(() => {
if (!dirty) {
return
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ""
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () => window.removeEventListener("beforeunload", handleBeforeUnload)
}, [dirty])
function handleAdd() {
setItems((current) => normalizeRows([...current, newMenuItem()]))
}
function handleChange(id: string, values: Partial<SupportNavigationMenuItem>) {
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...values } : item)))
}
function handleDelete(id: string) {
const item = items.find((candidate) => candidate.id === id)
if (!item) {
return
}
if (!window.confirm(t("supportConfig.confirmDelete", { title: item.title || t("supportConfig.untitled") }))) {
return
}
setItems((current) => normalizeRows(current.filter((candidate) => candidate.id !== id)))
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
setItems((current) => {
const oldIndex = current.findIndex((item) => item.id === active.id)
const newIndex = current.findIndex((item) => item.id === over.id)
if (oldIndex < 0 || newIndex < 0) {
return current
}
return normalizeRows(arrayMove(current, oldIndex, newIndex))
})
}
async function handleSave() {
try {
setSaving(true)
const config = await saveSupportConfigAdmin({ navigationMenu: items })
const saved = normalizeRows(config.navigationMenu)
setItems(saved)
setSavedSnapshot(serializeRows(saved))
setFieldErrors([])
toast.success(t("supportConfig.saved"))
} catch (error) {
if (isApiRequestError(error)) {
setFieldErrors(extractConfigFieldErrors(error.data))
}
toast.error(error instanceof Error ? error.message : t("supportConfig.saveFailed"))
} finally {
setSaving(false)
}
}
return (
<DashboardPage>
<DashboardToolbar
actions={
<>
<Button type="button" variant="outline" onClick={() => void loadConfig()} disabled={loading || saving}>
<RefreshCwIcon className={cn((loading || saving) && "animate-spin")} />
{t("supportConfig.refresh")}
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={loading || saving || !dirty}>
<SaveIcon />
{saving ? t("supportConfig.saving") : t("supportConfig.save")}
</Button>
</>
}
>
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight">{t("supportConfig.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("supportConfig.description")}</p>
</div>
</DashboardToolbar>
<section className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-sm font-medium">{t("supportConfig.navigationTitle")}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t("supportConfig.navigationDescription")}</p>
</div>
<Button type="button" variant="outline" onClick={handleAdd} disabled={loading || saving}>
<PlusIcon />
{t("supportConfig.addNavigation")}
</Button>
</div>
{fieldErrors.length > 0 ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<div className="font-medium">{t("supportConfig.validationFailed")}</div>
<ul className="mt-1 list-disc space-y-1 pl-5">
{fieldErrors.map((error) => (
<li key={`${error.path}-${error.code}`}>{error.path ? `${error.path}: ${error.message}` : error.message}</li>
))}
</ul>
</div>
) : null}
<DashboardTableShell>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">{t("supportConfig.sort")}</TableHead>
<TableHead className="min-w-44">{t("supportConfig.menuTitle")}</TableHead>
<TableHead className="min-w-64">{t("supportConfig.menuURL")}</TableHead>
<TableHead className="w-40">{t("supportConfig.target")}</TableHead>
<TableHead className="w-28">{t("supportConfig.visible")}</TableHead>
<TableHead className="w-20 text-right">{t("supportConfig.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<DashboardTableStateRow colSpan={6} loading loadingText={t("supportConfig.loading")} />
) : items.length === 0 ? (
<DashboardTableStateRow colSpan={6} emptyText={t("supportConfig.emptyNavigation")} />
) : (
<SortableContext items={items.map((item) => item.id)} strategy={verticalListSortingStrategy}>
{items.map((item) => (
<NavigationMenuRow
key={item.id}
item={item}
disabled={saving}
canDelete={canDelete}
onChange={handleChange}
onDelete={handleDelete}
/>
))}
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</DashboardTableShell>
</section>
</DashboardPage>
)
}
function extractConfigFieldErrors(data: unknown): ConfigFieldError[] {
if (!data || typeof data !== "object" || !("errors" in data)) {
return []
}
const errors = (data as { errors?: unknown }).errors
if (!Array.isArray(errors)) {
return []
}
return errors.filter((item): item is ConfigFieldError => {
return Boolean(
item &&
typeof item === "object" &&
"path" in item &&
"code" in item &&
"message" in item &&
typeof (item as ConfigFieldError).path === "string" &&
typeof (item as ConfigFieldError).code === "string" &&
typeof (item as ConfigFieldError).message === "string"
)
})
}
function NavigationMenuRow({ item, disabled, canDelete, onChange, onDelete }: NavigationMenuRowProps) {
const t = useI18n()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
disabled,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<TableRow ref={setNodeRef} style={style} className={cn(isDragging && "relative z-10 bg-muted/60 shadow-sm")}>
<TableCell className="w-12">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 cursor-grab rounded-md active:cursor-grabbing"
disabled={disabled}
aria-label={t("supportConfig.dragNavigation", { title: item.title || t("supportConfig.untitled") })}
{...attributes}
{...listeners}
>
<GripVerticalIcon />
</Button>
</TableCell>
<TableCell className="min-w-44 whitespace-normal">
<Label className="sr-only" htmlFor={`support-nav-title-${item.id}`}>{t("supportConfig.menuTitle")}</Label>
<Input
id={`support-nav-title-${item.id}`}
value={item.title}
onChange={(event) => onChange(item.id, { title: event.target.value })}
placeholder={t("supportConfig.titlePlaceholder")}
disabled={disabled}
maxLength={64}
/>
</TableCell>
<TableCell className="min-w-64 whitespace-normal">
<Label className="sr-only" htmlFor={`support-nav-url-${item.id}`}>{t("supportConfig.menuURL")}</Label>
<div className="flex min-w-0 items-center gap-2">
<Input
id={`support-nav-url-${item.id}`}
value={item.url}
onChange={(event) => onChange(item.id, { url: event.target.value })}
placeholder="/support/docs"
disabled={disabled}
/>
{item.url ? (
<a
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
href={item.url}
target="_blank"
rel="noreferrer"
aria-label={t("supportConfig.openURL")}
title={t("supportConfig.openURL")}
>
<ExternalLinkIcon className="size-4" />
</a>
) : null}
</div>
</TableCell>
<TableCell className="w-40">
<Select value={item.openInNewWindow ? "_blank" : "_self"} onValueChange={(value) => onChange(item.id, { openInNewWindow: value === "_blank" })} disabled={disabled}>
<SelectTrigger className="w-36 rounded-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="_self">{t("supportConfig.targetSelf")}</SelectItem>
<SelectItem value="_blank">{t("supportConfig.targetBlank")}</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="w-28">
<Switch
checked={item.visible}
onCheckedChange={(checked) => onChange(item.id, { visible: checked })}
disabled={disabled}
aria-label={t("supportConfig.toggleNavigation", { title: item.title || t("supportConfig.untitled") })}
/>
</TableCell>
<TableCell className="w-20 text-right">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 rounded-md text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={disabled || !canDelete}
onClick={() => onDelete(item.id)}
aria-label={t("supportConfig.deleteNavigation", { title: item.title || t("supportConfig.untitled") })}
>
<Trash2Icon />
</Button>
</TableCell>
</TableRow>
)
}