fix(admin): add client-side search, pagination, toast feedback, confirm dialogs and test result display
Local Build and Deploy / deploy (push) Successful in 1m21s
Local Build and Deploy / deploy (push) Successful in 1m21s
- Rewrite manage/import/presets/test tabs as client component with sonner toasts - Add client-side search filtering (no page reload) - Add pagination (25 per page) - Add confirm dialog before delete and clear-all - Show test results inline after submission - Add toast feedback for import, preset apply, save, delete - Add prev/next pagination labels
This commit is contained in:
1 parent
98d4b715df
commit
6ba8928791
4 files changed
+580
-342
No files matched your search
@@ -1,17 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
applyPreset,
|
||||
bulkImportPermissions,
|
||||
clearAllPermissions,
|
||||
deletePermission,
|
||||
testRankPermission,
|
||||
upsertPermission,
|
||||
} from "@/actions/admin-housekeeping";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { HousekeepingClient } from "./client";
|
||||
import { ManageClient } from "./client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -46,8 +38,6 @@ export default async function AdminHousekeeping({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
mode?: string;
|
||||
tab?:
|
||||
| "overview"
|
||||
| "manage"
|
||||
@@ -66,9 +56,7 @@ export default async function AdminHousekeeping({
|
||||
|
||||
const t = await getTranslations("pages.admin.housekeeping");
|
||||
const sp = await searchParams;
|
||||
const q = (sp.q ?? "").trim();
|
||||
const tab = sp.tab ?? "overview";
|
||||
const groupFilter = (sp.group ?? "").trim();
|
||||
|
||||
type HKPerm = Awaited<
|
||||
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
||||
@@ -77,13 +65,6 @@ export default async function AdminHousekeeping({
|
||||
.findMany({ orderBy: [{ groupName: "asc" }, { permission: "asc" }] })
|
||||
.catch(() => []);
|
||||
|
||||
const filtered = allPermissions.filter((p) => {
|
||||
if (q && !p.permission.toLowerCase().includes(q.toLowerCase()))
|
||||
return false;
|
||||
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const ranks = allPermissions.map((p) => p.minRank);
|
||||
const maxRank = ranks.length ? Math.max(...ranks) : 0;
|
||||
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
|
||||
@@ -108,6 +89,8 @@ export default async function AdminHousekeeping({
|
||||
}
|
||||
}
|
||||
|
||||
const allGroups = [...new Set([...GROUPS, ...Object.keys(groupCounts)])];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="admin-tabs mb-4">
|
||||
@@ -258,242 +241,39 @@ export default async function AdminHousekeeping({
|
||||
)}
|
||||
|
||||
{tab === "manage" && (
|
||||
<>
|
||||
<div className="admin-filter-bar">
|
||||
<form className="flex gap-2 flex-1 flex-wrap">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder={t("searchPermission")}
|
||||
className="flex-1 min-w-[200px]"
|
||||
/>
|
||||
<select
|
||||
name="group"
|
||||
defaultValue={groupFilter}
|
||||
className="w-[180px]"
|
||||
>
|
||||
<option value="">{t("allGroups")}</option>
|
||||
{GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g} ({groupCounts[g] ?? 0})
|
||||
</option>
|
||||
))}
|
||||
{Object.keys(groupCounts)
|
||||
.filter((g) => !GROUPS.includes(g))
|
||||
.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g} ({groupCounts[g]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input type="hidden" name="tab" value="manage" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("search")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("addPermission")}</h2>
|
||||
<form action={upsertPermission} className="admin-card">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="permission"
|
||||
placeholder={t("permissionKey")}
|
||||
required
|
||||
className="min-w-[200px] flex-1"
|
||||
/>
|
||||
<input
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("minRank")}
|
||||
required
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
placeholder={t("description")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<select name="groupName" className="w-[150px]">
|
||||
<option value="">{t("noGroup")}</option>
|
||||
{GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
name="dependsOn"
|
||||
placeholder={t("dependsOn")}
|
||||
className="min-w-[160px] flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="admin-section-title">
|
||||
{t("permissions")}{" "}
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{filtered.length}
|
||||
</span>
|
||||
</h2>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="admin-empty">{t("noPermissions")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[40px]">#</th>
|
||||
<th>{t("colPermission")}</th>
|
||||
<th>{t("colDescription")}</th>
|
||||
<th className="w-[120px]">{t("colGroup")}</th>
|
||||
<th className="w-[100px]">{t("colMinRank")}</th>
|
||||
<th className="w-[180px]">{t("colDepends")}</th>
|
||||
<th className="w-[80px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p, i) => (
|
||||
<tr key={String(p.id)}>
|
||||
<td className="muted w-[40px]">{i + 1}</td>
|
||||
<td>
|
||||
<strong>{p.permission}</strong>
|
||||
</td>
|
||||
<td className="muted">{p.description ?? "—"}</td>
|
||||
<td>
|
||||
{p.groupName ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-xs font-medium">
|
||||
{p.groupName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs opacity-50">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form
|
||||
action={upsertPermission}
|
||||
style={{ display: "flex", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="permission"
|
||||
value={p.permission}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="description"
|
||||
value={p.description ?? ""}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="groupName"
|
||||
value={p.groupName ?? ""}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="dependsOn"
|
||||
value={p.dependsOn ?? ""}
|
||||
/>
|
||||
<input
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={p.minRank}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{p.dependsOn ? (
|
||||
<span
|
||||
className={
|
||||
permSet.has(p.dependsOn)
|
||||
? "opacity-70"
|
||||
: "opacity-100 font-semibold"
|
||||
}
|
||||
style={
|
||||
!permSet.has(p.dependsOn)
|
||||
? { color: "var(--color-danger)" }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{p.dependsOn}
|
||||
{!permSet.has(p.dependsOn)
|
||||
? " " + t("missing")
|
||||
: ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="opacity-50">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={deletePermission}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="id"
|
||||
value={String(p.id)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
<ManageClient
|
||||
permissions={allPermissions}
|
||||
groups={allGroups}
|
||||
groupCounts={groupCounts}
|
||||
permSet={permSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "import" && (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("exportTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("exportHint")}
|
||||
</p>
|
||||
<HousekeepingClient />
|
||||
</section>
|
||||
<section className="mt-8">
|
||||
<h2 className="admin-section-title">{t("importTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("importHint")}
|
||||
</p>
|
||||
<form action={bulkImportPermissions} className="admin-card">
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
name="json"
|
||||
placeholder={t("importPlaceholder")}
|
||||
required
|
||||
rows={8}
|
||||
className="w-full font-mono text-sm"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary self-start">
|
||||
{t("import")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
<ManageClient
|
||||
permissions={allPermissions}
|
||||
groups={allGroups}
|
||||
groupCounts={groupCounts}
|
||||
permSet={permSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "presets" && (
|
||||
<ManageClient
|
||||
permissions={allPermissions}
|
||||
groups={allGroups}
|
||||
groupCounts={groupCounts}
|
||||
permSet={permSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "test" && (
|
||||
<ManageClient
|
||||
permissions={allPermissions}
|
||||
groups={allGroups}
|
||||
groupCounts={groupCounts}
|
||||
permSet={permSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "ranks" && (
|
||||
@@ -539,7 +319,7 @@ export default async function AdminHousekeeping({
|
||||
{p.permission}
|
||||
{p.dependsOn ? (
|
||||
<span className="opacity-50 ml-1">
|
||||
→ {p.dependsOn}
|
||||
→ {p.dependsOn}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
@@ -556,80 +336,6 @@ export default async function AdminHousekeeping({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "presets" && (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("presets")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("presetsHint")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{(["moderator", "admin", "superadmin"] as const).map((preset) => {
|
||||
const labels: Record<string, string> = {
|
||||
moderator: t("presetModerator"),
|
||||
admin: t("presetAdmin"),
|
||||
superadmin: t("presetSuperAdmin"),
|
||||
};
|
||||
const descs: Record<string, string> = {
|
||||
moderator: t("presetModeratorDesc"),
|
||||
admin: t("presetAdminDesc"),
|
||||
superadmin: t("presetSuperAdminDesc"),
|
||||
};
|
||||
return (
|
||||
<div key={preset} className="admin-card">
|
||||
<h3 className="font-bold text-lg mb-1">{labels[preset]}</h3>
|
||||
<p className="text-xs opacity-60 mb-3">{descs[preset]}</p>
|
||||
<form action={applyPreset}>
|
||||
<input type="hidden" name="preset" value={preset} />
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
{t("applyPreset")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<form
|
||||
action={clearAllPermissions}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (confirm(t("confirmClearAll")))
|
||||
(e.target as HTMLFormElement).requestSubmit();
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("clearAll")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "test" && (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("testTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("testHint")}
|
||||
</p>
|
||||
<form
|
||||
action={testRankPermission}
|
||||
className="admin-card flex gap-2 flex-wrap"
|
||||
>
|
||||
<input
|
||||
name="testRank"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("rank")}
|
||||
required
|
||||
className="w-[150px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("test")}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "audit" && (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("auditTitle")}</h2>
|
||||
|
||||
Reference in new issue
Block a user