Add shared ConfirmDialog; upgrade wordfilter with client actions and toasts.
Local Build and Deploy / deploy (push) Successful in 1m6s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 20:28:43 +02:00
1 parent 1039044e48
commit a2ca9b3c23
9 files changed
+366 -149

No files matched your search

+25 -11
View File
@@ -4,35 +4,49 @@ import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import {
actionError,
actionOk,
type ActionResult,
} from "@/lib/safe-action-shared";
import { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
export async function addWord(formData: FormData): Promise<void> { export async function addWord(input: {
word: string;
}): Promise<ActionResult<{ id: string }>> {
await requirePermission(PERMS.WORDFILTER_EDIT); await requirePermission(PERMS.WORDFILTER_EDIT);
const word = String(formData.get("word") ?? "") const word = String(input.word ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!word) return; if (!word) return actionError("Word is required");
try { try {
await prisma.websiteWordfilter.create({ data: { word } }); const created = await prisma.websiteWordfilter.create({ data: { word } });
reloadWordFilter();
await rcon.updateWordFilter(); await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(created.id) });
} catch { } catch {
// ignore (e.g. duplicate word) — page re-renders current state return actionError("Could not add word (it may already exist)");
} }
revalidatePath("/admin/wordfilter");
} }
export async function deleteWord(formData: FormData): Promise<void> { export async function deleteWord(input: {
id: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.WORDFILTER_EDIT); await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC"); const raw = String(input.id ?? "").normalize("NFC");
if (!raw) return; if (!raw) return actionError("Missing word id");
try { try {
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } }); await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
reloadWordFilter();
await rcon.updateWordFilter(); await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk();
} catch { } catch {
// ignore (e.g. already removed) return actionError("Could not remove word");
} }
revalidatePath("/admin/wordfilter");
} }
+10 -1
View File
@@ -4,10 +4,12 @@ import Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { deleteFavicon, saveFavicon } from "@/actions/save-favicon"; import { deleteFavicon, saveFavicon } from "@/actions/save-favicon";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { FaviconGenerator } from "./favicon-generator"; import { FaviconGenerator } from "./favicon-generator";
export function FaviconForm({ currentUrl }: { currentUrl: string | null }) { export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
const t = useTranslations("pages.admin.favicon"); const t = useTranslations("pages.admin.favicon");
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [preview, setPreview] = useState<string | null>(currentUrl); const [preview, setPreview] = useState<string | null>(currentUrl);
@@ -33,7 +35,13 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
} }
async function handleDelete() { async function handleDelete() {
if (!confirm(t("confirmDelete"))) return; const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
cancelLabel: t("cancel"),
});
if (!ok) return;
setDeleting(true); setDeleting(true);
setMessage(null); setMessage(null);
const result = await deleteFavicon(); const result = await deleteFavicon();
@@ -66,6 +74,7 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{confirmDialog}
<div className="admin-card"> <div className="admin-card">
<h3 className="text-sm font-extrabold text-[var(--admin-text)] mb-4"> <h3 className="text-sm font-extrabold text-[var(--admin-text)] mb-4">
{t("uploadLabel")} {t("uploadLabel")}
+15 -59
View File
@@ -13,6 +13,7 @@ import {
exportPermissions, exportPermissions,
upsertPermission, upsertPermission,
} from "@/actions/admin-housekeeping"; } from "@/actions/admin-housekeeping";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -32,59 +33,6 @@ interface SharedProps {
permSet: Set<string>; permSet: Set<string>;
} }
/* ────────── Confirm Modal ────────── */
interface ConfirmModalProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "primary";
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
variant = "danger",
onConfirm,
onCancel,
}: ConfirmModalProps) {
const t = useTranslations("pages.admin.housekeeping");
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onCancel}
>
<div
className="admin-card max-w-sm w-full mx-4 p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-bold text-lg mb-2">{title}</h3>
<p className="text-sm opacity-70 mb-5">{message}</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={onCancel} className="btn btn-outline">
{cancelLabel ?? t("cancel")}
</button>
<button
type="button"
onClick={onConfirm}
className={variant === "danger" ? "btn btn-danger" : "btn btn-primary"}
>
{confirmLabel ?? t("confirm")}
</button>
</div>
</div>
</div>
);
}
/* ────────── Manage Section ────────── */ /* ────────── Manage Section ────────── */
export function ManageSection(props: SharedProps) { export function ManageSection(props: SharedProps) {
@@ -293,20 +241,24 @@ export function ManageSection(props: SharedProps) {
return ( return (
<div> <div>
<ConfirmModal <ConfirmDialog
open={confirmDel !== null} open={confirmDel !== null}
onOpenChange={(open) => {
if (!open) setConfirmDel(null);
}}
title={t("confirmDelete")} title={t("confirmDelete")}
message={ description={
confirmDel === "__bulk__" confirmDel === "__bulk__"
? t("confirmBulkDelete", { count: selected.size }) ? t("confirmBulkDelete", { count: selected.size })
: t("confirmDeletePermission") : t("confirmDeletePermission")
} }
confirmLabel={t("confirm")}
cancelLabel={t("cancel")}
onConfirm={() => onConfirm={() =>
confirmDel === "__bulk__" confirmDel === "__bulk__"
? handleBulkDelete() ? handleBulkDelete()
: handleDelete(confirmDel!) : handleDelete(confirmDel!)
} }
onCancel={() => setConfirmDel(null)}
/> />
<div className="admin-filter-bar"> <div className="admin-filter-bar">
@@ -663,12 +615,16 @@ export function PresetsSection() {
return ( return (
<section> <section>
<ConfirmModal <ConfirmDialog
open={confirmClear} open={confirmClear}
onOpenChange={(open) => {
if (!open) setConfirmClear(false);
}}
title={t("confirmClearAll")} title={t("confirmClearAll")}
message={t("confirmClearAllDesc")} description={t("confirmClearAllDesc")}
confirmLabel={t("confirm")}
cancelLabel={t("cancel")}
onConfirm={handleClearAll} onConfirm={handleClearAll}
onCancel={() => setConfirmClear(false)}
/> />
<h2 className="admin-section-title">{t("presets")}</h2> <h2 className="admin-section-title">{t("presets")}</h2>
+10 -1
View File
@@ -10,6 +10,7 @@ import {
updatePrefix, updatePrefix,
updatePrefixSettings, updatePrefixSettings,
} from "@/actions/prefixes"; } from "@/actions/prefixes";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -315,6 +316,7 @@ function PrefixDialog({
export function PrefixesClient({ canEdit }: { canEdit: boolean }) { export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const { run, isPending } = useServerAction(); const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [prefixes, setPrefixes] = useState<UserPrefix[]>([]); const [prefixes, setPrefixes] = useState<UserPrefix[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -370,7 +372,13 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
}, [fetchBlacklist, fetchSettings]); }, [fetchBlacklist, fetchSettings]);
const handleDeletePrefix = async (id: number) => { const handleDeletePrefix = async (id: number) => {
if (!confirm("Are you sure you want to delete this prefix?")) return; const ok = await confirm({
title: "Delete prefix",
description: "Are you sure you want to delete this prefix?",
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deletePrefixAction({ id }), { run(() => deletePrefixAction({ id }), {
successMessage: "Prefix deleted!", successMessage: "Prefix deleted!",
onSuccess: () => { onSuccess: () => {
@@ -463,6 +471,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
return ( return (
<> <>
{confirmDialog}
<PrefixDialog <PrefixDialog
editPrefix={editPrefix} editPrefix={editPrefix}
isOpen={dialogOpen} isOpen={dialogOpen}
+10 -77
View File
@@ -1,100 +1,33 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { StatusCard } from "@/components/admin/dashboard";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { WordFilterClient } from "./wordfilter-client";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type WordRow = { id: bigint; word: string };
export default async function AdminWordFilter() { export default async function AdminWordFilter() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("pages.admin.wordfilter"); const canEdit = canAccess(
permissions,
PERMS.WORDFILTER_EDIT,
session.user.rank,
);
let words: WordRow[] = []; let words: { id: string; word: string }[] = [];
try { try {
words = await prisma.websiteWordfilter.findMany({ const rows = await prisma.websiteWordfilter.findMany({
select: { id: true, word: true }, select: { id: true, word: true },
orderBy: { word: "asc" }, orderBy: { word: "asc" },
take: 500, take: 500,
}); });
words = rows.map((w) => ({ id: String(w.id), word: w.word }));
} catch { } catch {
words = []; words = [];
} }
return ( return <WordFilterClient words={words} canEdit={canEdit} />;
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("filteredWords")} value={words.length} icon="🚫" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">{t("addWord")}</h2>
<form action={addWord} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
name="word"
placeholder={t("wordToFilter")}
required
maxLength={255}
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
{t("add")}
</button>
</div>
</form>
</section>
<section className="mt-6">
<h2 className="admin-section-title">
{t("words")}{" "}
<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)]">
{words.length}
</span>
</h2>
{words.length === 0 ? (
<div className="admin-empty">{t("noWords")}</div>
) : (
<div className="admin-card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>{t("colId")}</th>
<th>{t("colWord")}</th>
<th />
</tr>
</thead>
<tbody>
{words.map((w) => (
<tr key={String(w.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{String(w.id)}
</td>
<td>
<strong>{w.word}</strong>
</td>
<td>
<form action={deleteWord}>
<input type="hidden" name="id" value={String(w.id)} />
<button type="submit" className="btn btn-danger">
{t("delete")}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
} }
@@ -0,0 +1,145 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
import { StatusCard } from "@/components/admin/dashboard";
import { useServerAction } from "@/hooks/use-server-action";
export type WordFilterRow = { id: string; word: string };
export function WordFilterClient({
words,
canEdit,
}: {
words: WordFilterRow[];
canEdit: boolean;
}) {
const t = useTranslations("pages.admin.wordfilter");
const { run, isPending } = useServerAction();
const [word, setWord] = useState("");
const [pendingDelete, setPendingDelete] = useState<WordFilterRow | null>(
null,
);
function handleAdd(e: React.FormEvent) {
e.preventDefault();
const trimmed = word.trim();
if (!trimmed || !canEdit) return;
run(() => addWord({ word: trimmed }), {
successMessage: t("added"),
errorMessage: t("addError"),
onSuccess: () => setWord(""),
});
}
function handleConfirmDelete() {
if (!pendingDelete) return;
const id = pendingDelete.id;
run(() => deleteWord({ id }), {
successMessage: t("deleted"),
errorMessage: t("deleteError"),
onSuccess: () => setPendingDelete(null),
});
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("filteredWords")} value={words.length} icon="🚫" />
</div>
{canEdit && (
<section className="mt-6">
<h2 className="admin-section-title">{t("addWord")}</h2>
<form onSubmit={handleAdd} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
value={word}
onChange={(e) => setWord(e.target.value)}
placeholder={t("form.wordPlaceholder")}
required
maxLength={255}
disabled={isPending}
className="flex-1 min-w-[200px]"
/>
<button
type="submit"
disabled={isPending || !word.trim()}
className="btn btn-primary"
>
{isPending ? t("form.saving") : t("form.save")}
</button>
</div>
</form>
</section>
)}
<section className="mt-6">
<h2 className="admin-section-title">
{t("words")}{" "}
<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)]">
{words.length}
</span>
</h2>
{words.length === 0 ? (
<div className="admin-empty">{t("noWords")}</div>
) : (
<div className="admin-card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>{t("colId")}</th>
<th>{t("colWord")}</th>
{canEdit && <th>{t("colActions")}</th>}
</tr>
</thead>
<tbody>
{words.map((w) => (
<tr key={w.id}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{w.id}
</td>
<td>
<strong>{w.word}</strong>
</td>
{canEdit && (
<td>
<button
type="button"
className="btn btn-danger"
disabled={isPending}
onClick={() => setPendingDelete(w)}
>
{t("delete")}
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
title={t("confirmDeleteTitle")}
description={
pendingDelete
? t("confirmDelete", { word: pendingDelete.word })
: ""
}
confirmLabel={t("delete")}
cancelLabel={t("form.cancel")}
loading={isPending}
onConfirm={handleConfirmDelete}
/>
</main>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
loading?: boolean;
onConfirm: () => void | Promise<void>;
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "danger",
loading,
onConfirm,
}: ConfirmDialogProps) {
const [busy, setBusy] = useState(false);
const isLoading = loading ?? busy;
async function handleConfirm() {
setBusy(true);
try {
await onConfirm();
onOpenChange(false);
} finally {
setBusy(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (isLoading) return;
onOpenChange(next);
}}
>
<DialogContent showCloseButton={!isLoading}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
{cancelLabel}
</Button>
<Button
type="button"
variant={variant === "danger" ? "destructive" : "default"}
disabled={isLoading}
onClick={handleConfirm}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export type ConfirmOptions = {
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
};
/** Imperative confirm — returns a Promise. Render `{dialog}` in the tree. */
export function useConfirmDialog() {
const [opts, setOpts] = useState<(ConfirmOptions & { open: boolean }) | null>(
null,
);
const resolver = useRef<((value: boolean) => void) | null>(null);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
resolver.current = resolve;
setOpts({ ...options, open: true });
});
}, []);
const settle = useCallback((value: boolean) => {
const resolve = resolver.current;
resolver.current = null;
setOpts(null);
resolve?.(value);
}, []);
const dialog = opts ? (
<ConfirmDialog
open={opts.open}
onOpenChange={(open) => {
if (!open) settle(false);
}}
title={opts.title}
description={opts.description}
confirmLabel={opts.confirmLabel}
cancelLabel={opts.cancelLabel}
variant={opts.variant}
onConfirm={() => settle(true)}
/>
) : null;
return { confirm, dialog };
}
+11
View File
@@ -1943,10 +1943,18 @@
"wordfilter": { "wordfilter": {
"title": "Word Filter", "title": "Word Filter",
"subtitle": "Manage filtered words and phrases in the hotel", "subtitle": "Manage filtered words and phrases in the hotel",
"filteredWords": "Filtered words",
"addWord": "Add word", "addWord": "Add word",
"addWords": "Add words", "addWords": "Add words",
"words": "Words",
"noWords": "No filtered words yet", "noWords": "No filtered words yet",
"delete": "Delete",
"confirmDeleteTitle": "Remove word",
"confirmDelete": "Remove \"{word}\" from the filter?", "confirmDelete": "Remove \"{word}\" from the filter?",
"added": "Word added to filter",
"deleted": "Word removed from filter",
"addError": "Could not add word (it may already exist)",
"deleteError": "Could not remove word",
"colId": "ID", "colId": "ID",
"colWord": "Word", "colWord": "Word",
"colAddedBy": "Added by", "colAddedBy": "Added by",
@@ -2217,6 +2225,9 @@
"uploading": "Uploading…", "uploading": "Uploading…",
"delete": "Remove", "delete": "Remove",
"deleting": "Removing…", "deleting": "Removing…",
"cancel": "Cancel",
"saved": "Favicon saved successfully!",
"deleted": "Favicon removed successfully!",
"error": "Something went wrong", "error": "Something went wrong",
"confirmDelete": "Remove the favicon? The site will no longer show a custom favicon.", "confirmDelete": "Remove the favicon? The site will no longer show a custom favicon.",
"generator": { "generator": {
+9
View File
@@ -1790,10 +1790,18 @@
"wordfilter": { "wordfilter": {
"title": "Filtro parole", "title": "Filtro parole",
"subtitle": "Gestisci parole e frasi filtrate nell'hotel", "subtitle": "Gestisci parole e frasi filtrate nell'hotel",
"filteredWords": "Parole filtrate",
"addWord": "Aggiungi parola", "addWord": "Aggiungi parola",
"addWords": "Aggiungi parole", "addWords": "Aggiungi parole",
"words": "Parole",
"noWords": "Ancora nessuna parola filtrata", "noWords": "Ancora nessuna parola filtrata",
"delete": "Elimina",
"confirmDeleteTitle": "Rimuovi parola",
"confirmDelete": "Rimuovere \"{word}\" dal filtro?", "confirmDelete": "Rimuovere \"{word}\" dal filtro?",
"added": "Parola aggiunta al filtro",
"deleted": "Parola rimossa dal filtro",
"addError": "Impossibile aggiungere la parola (potrebbe già esistere)",
"deleteError": "Impossibile rimuovere la parola",
"colId": "ID", "colId": "ID",
"colWord": "Parola", "colWord": "Parola",
"colAddedBy": "Aggiunto da", "colAddedBy": "Aggiunto da",
@@ -2193,6 +2201,7 @@
"uploading": "Uploading…", "uploading": "Uploading…",
"delete": "Remove", "delete": "Remove",
"deleting": "Removing…", "deleting": "Removing…",
"cancel": "Annulla",
"saved": "Favicon saved successfully!", "saved": "Favicon saved successfully!",
"deleted": "Favicon removed successfully!", "deleted": "Favicon removed successfully!",
"error": "Something went wrong", "error": "Something went wrong",