"use client"; import { useRouter, useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { applyPreset, bulkDeletePermissions, bulkImportPermissions, clearAllPermissions, deletePermission, exportPermissions, upsertPermission, } from "@/actions/admin-housekeeping"; import { ConfirmDialog } from "@/components/admin/confirm-dialog"; const PAGE_SIZE = 25; interface HKPerm { id: bigint; permission: string; minRank: number; description: string | null; groupName: string | null; dependsOn: string | null; } interface SharedProps { permissions: HKPerm[]; groups: string[]; groupCounts: Record; permSet: Set; } /* ────────── Manage Section ────────── */ export function ManageSection(props: SharedProps) { const t = useTranslations("pages.admin.housekeeping"); const router = useRouter(); const sp = useSearchParams(); const [search, setSearch] = useState(sp.get("search") ?? ""); const [groupFilter, setGroupFilter] = useState(sp.get("group") ?? ""); const [page, setPage] = useState(1); const [pendingDel, setPendingDel] = useState(null); const [selected, setSelected] = useState>(new Set()); const [selectAllMatching, setSelectAllMatching] = useState(false); const [busyBulk, setBusyBulk] = useState(false); const [confirmDel, setConfirmDel] = useState(null); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); const [sortKey, setSortKey] = useState("permission"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const searchTimer = useRef>(null); const formRef = useRef(null); const editRef = useRef(null); const filtered = useMemo(() => { return props.permissions.filter((p) => { if (search && !p.permission.toLowerCase().includes(search.toLowerCase())) return false; if (groupFilter && (p.groupName ?? "") !== groupFilter) return false; return true; }); }, [props.permissions, search, groupFilter]); const sorted = useMemo(() => { const arr = [...filtered]; arr.sort((a, b) => { let cmp = 0; switch (sortKey) { case "permission": cmp = a.permission.localeCompare(b.permission); break; case "minRank": cmp = a.minRank - b.minRank; break; case "groupName": cmp = (a.groupName ?? "").localeCompare(b.groupName ?? ""); break; case "description": cmp = (a.description ?? "").localeCompare(b.description ?? ""); break; case "dependsOn": cmp = (a.dependsOn ?? "").localeCompare(b.dependsOn ?? ""); break; } return sortDir === "asc" ? cmp : -cmp; }); return arr; }, [filtered, sortKey, sortDir]); const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); const paginated = sorted.slice( (safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE, ); const syncUrl = useCallback( (s: string, g: string) => { const params = new URLSearchParams(); if (s) params.set("search", s); if (g) params.set("group", g); const qs = params.toString(); router.replace(`/admin/housekeeping?tab=manage${qs ? `&${qs}` : ""}`, { scroll: false, }); }, [router], ); const debouncedSync = useCallback( (s: string, g: string) => { if (searchTimer.current) clearTimeout(searchTimer.current); searchTimer.current = setTimeout(() => syncUrl(s, g), 300); }, [syncUrl], ); useEffect(() => { return () => { if (searchTimer.current) clearTimeout(searchTimer.current); }; }, []); const handleUpsert = useCallback( async (formData: FormData) => { try { await upsertPermission(formData); toast.success(t("saved")); if (formRef.current) formRef.current.reset(); router.refresh(); } catch { toast.error(t("errorOccurred")); } }, [router, t], ); const handleDelete = useCallback( async (id: string) => { setPendingDel(id); try { const fd = new FormData(); fd.set("id", id); await deletePermission(fd); toast.success(t("deleted")); router.refresh(); } catch { toast.error(t("errorOccurred")); } setPendingDel(null); setConfirmDel(null); }, [router, t], ); const handleBulkDelete = useCallback(async () => { if (!selected.size) return; setBusyBulk(true); try { const ids = [...selected].map((s) => BigInt(s)); await bulkDeletePermissions(ids); toast.success(t("deleted")); setSelected(new Set()); router.refresh(); } catch { toast.error(t("errorOccurred")); } setBusyBulk(false); setConfirmDel(null); }, [selected, router, t]); const toggleSelect = useCallback((id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); setSelectAllMatching(false); }, []); const toggleAll = useCallback(() => { if (selected.size === paginated.length && !selectAllMatching) { setSelected(new Set()); } else { setSelected(new Set(paginated.map((p) => String(p.id)))); setSelectAllMatching(false); } }, [paginated, selected, selectAllMatching]); const handleSelectAllMatching = useCallback(() => { setSelected(new Set(sorted.map((p) => String(p.id)))); setSelectAllMatching(true); }, [sorted]); const handleSort = useCallback( (key: string) => { setSortDir((prev) => sortKey === key && prev === "asc" ? "desc" : "asc", ); setSortKey(key); setPage(1); }, [sortKey], ); const startEdit = useCallback((id: string, value: number) => { setEditingId(id); setEditValue(String(value)); setTimeout(() => editRef.current?.focus(), 0); }, []); const cancelEdit = useCallback(() => { setEditingId(null); setEditValue(""); }, []); const commitEdit = useCallback( async (p: HKPerm) => { const fd = new FormData(); fd.set("permission", p.permission); fd.set("minRank", editValue); fd.set("description", p.description ?? ""); fd.set("groupName", p.groupName ?? ""); fd.set("dependsOn", p.dependsOn ?? ""); await handleUpsert(fd); setEditingId(null); }, [editValue, handleUpsert], ); const thClass = "cursor-pointer select-none hover:opacity-80 transition-opacity"; const sortIcon = (key: string) => { if (sortKey !== key) return " \u2195"; return sortDir === "asc" ? " \u2191" : " \u2193"; }; return (
{ if (!open) setConfirmDel(null); }} title={t("confirmDelete")} description={ confirmDel === "__bulk__" ? t("confirmBulkDelete", { count: selected.size }) : t("confirmDeletePermission") } confirmLabel={t("confirm")} cancelLabel={t("cancel")} onConfirm={async () => { if (confirmDel === "__bulk__") { await handleBulkDelete(); } else if (confirmDel !== null) { await handleDelete(confirmDel); } }} />
{ const v = e.target.value; setSearch(v); setPage(1); debouncedSync(v, groupFilter); }} placeholder={t("searchPermission")} className="flex-1 min-w-[200px]" />

{t("addPermission")}

{ e.preventDefault(); handleUpsert(new FormData(e.currentTarget)); }} className="admin-card" >

{t("permissions")}{" "} {filtered.length}

{selected.size > 0 && selected.size < sorted.length && ( )} {selected.size > 0 && ( )}
{filtered.length === 0 ? (
{t("noPermissions")}
) : (
{paginated.map((p, i) => { const sid = String(p.id); return ( ); })}
{selectAllMatching ? ( { setSelected(new Set()); setSelectAllMatching(false); }} /> ) : ( 0 && selected.size === paginated.length } onChange={toggleAll} /> )} # handleSort("permission")} > {t("colPermission")} {sortIcon("permission")} handleSort("description")} > {t("colDescription")} {sortIcon("description")} handleSort("groupName")} > {t("colGroup")} {sortIcon("groupName")} handleSort("minRank")} > {t("colMinRank")} {sortIcon("minRank")} {t("colDepends")}
toggleSelect(sid)} /> {(safePage - 1) * PAGE_SIZE + i + 1} {p.permission} {p.description || "\u2014"} {p.groupName ? ( {p.groupName} ) : ( \u2014 )} {editingId === sid ? (
setEditValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitEdit(p); } else if (e.key === "Escape") { cancelEdit(); } }} style={{ width: 70 }} />
) : ( )}
{p.dependsOn ? ( {p.dependsOn} {!props.permSet.has(p.dependsOn) ? ` ${t("missing")}` : ""} ) : ( \u2014 )}
)} {totalPages > 1 && (
{safePage} / {totalPages}
)}
); } /* ────────── Presets Section ────────── */ export function PresetsSection() { const t = useTranslations("pages.admin.housekeeping"); const router = useRouter(); const [busy, setBusy] = useState(null); const [confirmClear, setConfirmClear] = useState(false); const handleApplyPreset = useCallback( async (preset: string) => { setBusy(preset); try { const fd = new FormData(); fd.set("preset", preset); await applyPreset(fd); toast.success(t("presetApplied")); router.refresh(); } catch { toast.error(t("errorOccurred")); } setBusy(null); }, [router, t], ); const handleClearAll = useCallback(async () => { setBusy("__clear__"); try { await clearAllPermissions(); toast.success(t("cleared")); router.refresh(); } catch { toast.error(t("errorOccurred")); } setBusy(null); setConfirmClear(false); }, [router, t]); return (
{ if (!open) setConfirmClear(false); }} title={t("confirmClearAll")} description={t("confirmClearAllDesc")} confirmLabel={t("confirm")} cancelLabel={t("cancel")} onConfirm={handleClearAll} />

{t("presets")}

{t("presetsHint")}

{(["moderator", "admin", "superadmin"] as const).map((preset) => { const labels: Record = { moderator: t("presetModerator"), admin: t("presetAdmin"), superadmin: t("presetSuperAdmin"), }; const descs: Record = { moderator: t("presetModeratorDesc"), admin: t("presetAdminDesc"), superadmin: t("presetSuperAdminDesc"), }; const loading = busy === preset; return (

{labels[preset]}

{descs[preset]}

); })}
); } /* ────────── Test Section ────────── */ export function TestSection({ permissions }: { permissions: HKPerm[] }) { const t = useTranslations("pages.admin.housekeeping"); const [testRank, setTestRank] = useState(""); const [testResult, setTestResult] = useState<{ rank: number; permissions: string[]; } | null>(null); const handleTest = useCallback(() => { const rank = Number(testRank); if (!Number.isFinite(rank) || rank < 0) return toast.error(t("invalidRank")); const matching = permissions.filter((p) => p.minRank <= rank); setTestResult({ rank, permissions: matching.map((p) => p.permission) }); toast.success(t("testDone", { count: matching.length })); }, [testRank, permissions, t]); return (

{t("testTitle")}

{t("testHint")}

setTestRank(e.target.value)} type="number" min={0} placeholder={t("rank")} className="w-[150px]" />
{testResult && (
{t("rank")} {testResult.rank}: {testResult.permissions.length}{" "} {t("permissions")}
{testResult.permissions.map((p) => (
{p}
))}
)}
); } /* ────────── Import Section ────────── */ export function ImportSection() { const t = useTranslations("pages.admin.housekeeping"); const router = useRouter(); const textareaRef = useRef(null); const [importing, setImporting] = useState(false); const handleBulkImport = useCallback( async (e: React.FormEvent) => { e.preventDefault(); const fd = new FormData(e.currentTarget); const raw = String(fd.get("json") ?? "").trim(); if (!raw) return toast.error(t("emptyInput")); try { JSON.parse(raw); } catch { return toast.error(t("invalidJson")); } setImporting(true); try { const result = await bulkImportPermissions(fd); if (result.errors.length > 0) { toast.error( t("importedWithErrors", { count: result.count, errors: result.errors.length, }), ); } else { toast.success(t("imported", { count: result.count })); } if (textareaRef.current) textareaRef.current.value = ""; router.refresh(); } catch { toast.error(t("errorOccurred")); } setImporting(false); }, [router, t], ); return (

{t("importTitle")}

{t("importHint")}