Add shared ConfirmDialog; upgrade wordfilter with client actions and toasts.
Local Build and Deploy / deploy (push) Successful in 1m6s
Local Build and Deploy / deploy (push) Successful in 1m6s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
1039044e48
commit
a2ca9b3c23
9 files changed
+366
-149
No files matched your search
@@ -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");
|
|
||||||
}
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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,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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user