chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardSupportCommunityCategoryAdmin } from "../_components/support-admin"
|
||||
|
||||
export default function DashboardSupportCommunityCategoriesPage() {
|
||||
return <DashboardSupportCommunityCategoryAdmin />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/community/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/community/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardSupportCommunityAdmin } from "../_components/support-admin"
|
||||
|
||||
export default function DashboardSupportCommunityPage() {
|
||||
return <DashboardSupportCommunityAdmin />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/config/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/config/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportConfigPanel } from "../_components/support-config-panel"
|
||||
|
||||
export default function DashboardSupportConfigPage() {
|
||||
return <SupportConfigPanel />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/docs/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/docs/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportDocsWorkbench } from "../_components/help-workbench"
|
||||
|
||||
export default function DashboardDocPage() {
|
||||
return <SupportDocsWorkbench />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function DashboardSupportPage() {
|
||||
redirect("/dashboard/support/docs")
|
||||
}
|
||||
Reference in New Issue
Block a user