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,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<string | null>(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 (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{confirmDialog}
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-extrabold text-[var(--admin-text)] mb-4">
|
||||
{t("uploadLabel")}
|
||||
|
||||
@@ -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<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 ────────── */
|
||||
|
||||
export function ManageSection(props: SharedProps) {
|
||||
@@ -293,20 +241,24 @@ export function ManageSection(props: SharedProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
open={confirmDel !== null}
|
||||
onOpenChange={(open) => {
|
||||
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)}
|
||||
/>
|
||||
|
||||
<div className="admin-filter-bar">
|
||||
@@ -663,12 +615,16 @@ export function PresetsSection() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
open={confirmClear}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmClear(false);
|
||||
}}
|
||||
title={t("confirmClearAll")}
|
||||
message={t("confirmClearAllDesc")}
|
||||
description={t("confirmClearAllDesc")}
|
||||
confirmLabel={t("confirm")}
|
||||
cancelLabel={t("cancel")}
|
||||
onConfirm={handleClearAll}
|
||||
onCancel={() => setConfirmClear(false)}
|
||||
/>
|
||||
|
||||
<h2 className="admin-section-title">{t("presets")}</h2>
|
||||
|
||||
@@ -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<UserPrefix[]>([]);
|
||||
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}
|
||||
<PrefixDialog
|
||||
editPrefix={editPrefix}
|
||||
isOpen={dialogOpen}
|
||||
|
||||
@@ -1,100 +1,33 @@
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
import { WordFilterClient } from "./wordfilter-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type WordRow = { id: bigint; word: string };
|
||||
|
||||
export default async function AdminWordFilter() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) {
|
||||
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 {
|
||||
words = await prisma.websiteWordfilter.findMany({
|
||||
const rows = await prisma.websiteWordfilter.findMany({
|
||||
select: { id: true, word: true },
|
||||
orderBy: { word: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
words = rows.map((w) => ({ id: String(w.id), word: w.word }));
|
||||
} catch {
|
||||
words = [];
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
return <WordFilterClient words={words} canEdit={canEdit} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user