From a2ca9b3c233395de3041354baf2e3afb00e98be6 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 17 Jul 2026 20:28:43 +0200 Subject: [PATCH] Add shared ConfirmDialog; upgrade wordfilter with client actions and toasts. Co-authored-by: Cursor --- src/actions/admin-wordfilter.ts | 36 +++-- src/app/admin/favicon/favicon-form.tsx | 11 +- src/app/admin/housekeeping/client.tsx | 74 ++------- src/app/admin/prefixes/prefixes-client.tsx | 11 +- src/app/admin/wordfilter/page.tsx | 87 ++--------- .../admin/wordfilter/wordfilter-client.tsx | 145 ++++++++++++++++++ src/components/admin/confirm-dialog.tsx | 131 ++++++++++++++++ src/messages/en.json | 11 ++ src/messages/it.json | 9 ++ 9 files changed, 366 insertions(+), 149 deletions(-) create mode 100644 src/app/admin/wordfilter/wordfilter-client.tsx create mode 100644 src/components/admin/confirm-dialog.tsx diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index cb52a94c..743d7ebf 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -4,35 +4,49 @@ import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; 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"; -export async function addWord(formData: FormData): Promise { +export async function addWord(input: { + word: string; +}): Promise> { await requirePermission(PERMS.WORDFILTER_EDIT); - const word = String(formData.get("word") ?? "") + const word = String(input.word ?? "") .normalize("NFC") .trim() .slice(0, 255); - if (!word) return; + if (!word) return actionError("Word is required"); try { - await prisma.websiteWordfilter.create({ data: { word } }); + const created = await prisma.websiteWordfilter.create({ data: { word } }); + reloadWordFilter(); await rcon.updateWordFilter(); + revalidatePath("/admin/wordfilter"); + return actionOk({ id: String(created.id) }); } 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 { +export async function deleteWord(input: { + id: string; +}): Promise { await requirePermission(PERMS.WORDFILTER_EDIT); - const raw = String(formData.get("id") ?? "").normalize("NFC"); - if (!raw) return; + const raw = String(input.id ?? "").normalize("NFC"); + if (!raw) return actionError("Missing word id"); try { await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } }); + reloadWordFilter(); await rcon.updateWordFilter(); + revalidatePath("/admin/wordfilter"); + return actionOk(); } catch { - // ignore (e.g. already removed) + return actionError("Could not remove word"); } - revalidatePath("/admin/wordfilter"); } diff --git a/src/app/admin/favicon/favicon-form.tsx b/src/app/admin/favicon/favicon-form.tsx index 8e5bb83d..38b08d30 100644 --- a/src/app/admin/favicon/favicon-form.tsx +++ b/src/app/admin/favicon/favicon-form.tsx @@ -4,10 +4,12 @@ import Image from "next/image"; import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; import { deleteFavicon, saveFavicon } from "@/actions/save-favicon"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { FaviconGenerator } from "./favicon-generator"; export function FaviconForm({ currentUrl }: { currentUrl: string | null }) { const t = useTranslations("pages.admin.favicon"); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [uploading, setUploading] = useState(false); const [deleting, setDeleting] = useState(false); const [preview, setPreview] = useState(currentUrl); @@ -33,7 +35,13 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) { } 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); setMessage(null); const result = await deleteFavicon(); @@ -66,6 +74,7 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) { return (
+ {confirmDialog}

{t("uploadLabel")} diff --git a/src/app/admin/housekeeping/client.tsx b/src/app/admin/housekeeping/client.tsx index 7f2bb06d..047519a8 100644 --- a/src/app/admin/housekeeping/client.tsx +++ b/src/app/admin/housekeeping/client.tsx @@ -13,6 +13,7 @@ import { exportPermissions, upsertPermission, } from "@/actions/admin-housekeeping"; +import { ConfirmDialog } from "@/components/admin/confirm-dialog"; const PAGE_SIZE = 25; @@ -32,59 +33,6 @@ interface SharedProps { permSet: Set; } -/* ────────── 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 ( -
-
e.stopPropagation()} - > -

{title}

-

{message}

-
- - -
-
-
- ); -} - /* ────────── Manage Section ────────── */ export function ManageSection(props: SharedProps) { @@ -293,20 +241,24 @@ export function ManageSection(props: SharedProps) { return (
- { + if (!open) setConfirmDel(null); + }} title={t("confirmDelete")} - message={ + description={ confirmDel === "__bulk__" ? t("confirmBulkDelete", { count: selected.size }) : t("confirmDeletePermission") } + confirmLabel={t("confirm")} + cancelLabel={t("cancel")} onConfirm={() => confirmDel === "__bulk__" ? handleBulkDelete() : handleDelete(confirmDel!) } - onCancel={() => setConfirmDel(null)} />
@@ -663,12 +615,16 @@ export function PresetsSection() { return (
- { + if (!open) setConfirmClear(false); + }} title={t("confirmClearAll")} - message={t("confirmClearAllDesc")} + description={t("confirmClearAllDesc")} + confirmLabel={t("confirm")} + cancelLabel={t("cancel")} onConfirm={handleClearAll} - onCancel={() => setConfirmClear(false)} />

{t("presets")}

diff --git a/src/app/admin/prefixes/prefixes-client.tsx b/src/app/admin/prefixes/prefixes-client.tsx index f8143e75..b6618ac1 100644 --- a/src/app/admin/prefixes/prefixes-client.tsx +++ b/src/app/admin/prefixes/prefixes-client.tsx @@ -10,6 +10,7 @@ import { updatePrefix, updatePrefixSettings, } from "@/actions/prefixes"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -315,6 +316,7 @@ function PrefixDialog({ export function PrefixesClient({ canEdit }: { canEdit: boolean }) { const { run, isPending } = useServerAction(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [prefixes, setPrefixes] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [page, setPage] = useState(1); @@ -370,7 +372,13 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) { }, [fetchBlacklist, fetchSettings]); 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 }), { successMessage: "Prefix deleted!", onSuccess: () => { @@ -463,6 +471,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) { return ( <> + {confirmDialog} ({ id: String(w.id), word: w.word })); } catch { words = []; } - return ( -
-
- -
- -
-

{t("addWord")}

-
-
- - -
-
-
- -
-

- {t("words")}{" "} - - {words.length} - -

- {words.length === 0 ? ( -
{t("noWords")}
- ) : ( -
- - - - - - - - - {words.map((w) => ( - - - - - - ))} - -
{t("colId")}{t("colWord")} -
- {String(w.id)} - - {w.word} - -
- - -
-
-
- )} -
-
- ); + return ; } diff --git a/src/app/admin/wordfilter/wordfilter-client.tsx b/src/app/admin/wordfilter/wordfilter-client.tsx new file mode 100644 index 00000000..210b18ff --- /dev/null +++ b/src/app/admin/wordfilter/wordfilter-client.tsx @@ -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( + 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 ( +
+
+ +
+ + {canEdit && ( +
+

{t("addWord")}

+
+
+ setWord(e.target.value)} + placeholder={t("form.wordPlaceholder")} + required + maxLength={255} + disabled={isPending} + className="flex-1 min-w-[200px]" + /> + +
+
+
+ )} + +
+

+ {t("words")}{" "} + + {words.length} + +

+ {words.length === 0 ? ( +
{t("noWords")}
+ ) : ( +
+ + + + + + {canEdit && } + + + + {words.map((w) => ( + + + + {canEdit && ( + + )} + + ))} + +
{t("colId")}{t("colWord")}{t("colActions")}
+ {w.id} + + {w.word} + + +
+
+ )} +
+ + { + 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} + /> +
+ ); +} diff --git a/src/components/admin/confirm-dialog.tsx b/src/components/admin/confirm-dialog.tsx new file mode 100644 index 00000000..1d5e7d66 --- /dev/null +++ b/src/components/admin/confirm-dialog.tsx @@ -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; +}; + +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 ( + { + if (isLoading) return; + onOpenChange(next); + }} + > + + + {title} + {description} + + + + + + + + ); +} + +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((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 ? ( + { + 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 }; +} diff --git a/src/messages/en.json b/src/messages/en.json index fd400b6c..433e78fb 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1943,10 +1943,18 @@ "wordfilter": { "title": "Word Filter", "subtitle": "Manage filtered words and phrases in the hotel", + "filteredWords": "Filtered words", "addWord": "Add word", "addWords": "Add words", + "words": "Words", "noWords": "No filtered words yet", + "delete": "Delete", + "confirmDeleteTitle": "Remove word", "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", "colWord": "Word", "colAddedBy": "Added by", @@ -2217,6 +2225,9 @@ "uploading": "Uploading…", "delete": "Remove", "deleting": "Removing…", + "cancel": "Cancel", + "saved": "Favicon saved successfully!", + "deleted": "Favicon removed successfully!", "error": "Something went wrong", "confirmDelete": "Remove the favicon? The site will no longer show a custom favicon.", "generator": { diff --git a/src/messages/it.json b/src/messages/it.json index e5770272..64017425 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -1790,10 +1790,18 @@ "wordfilter": { "title": "Filtro parole", "subtitle": "Gestisci parole e frasi filtrate nell'hotel", + "filteredWords": "Parole filtrate", "addWord": "Aggiungi parola", "addWords": "Aggiungi parole", + "words": "Parole", "noWords": "Ancora nessuna parola filtrata", + "delete": "Elimina", + "confirmDeleteTitle": "Rimuovi parola", "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", "colWord": "Parola", "colAddedBy": "Aggiunto da", @@ -2193,6 +2201,7 @@ "uploading": "Uploading…", "delete": "Remove", "deleting": "Removing…", + "cancel": "Annulla", "saved": "Favicon saved successfully!", "deleted": "Favicon removed successfully!", "error": "Something went wrong",