feat(ui): confirm destructive admin actions
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-14 09:10:32 +03:30
parent 4e24b96068
commit fc94ceb9f5
10 changed files with 171 additions and 41 deletions

View File

@@ -0,0 +1,58 @@
"use client";
import type { ReactNode } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
type ConfirmActionProps = {
trigger: ReactNode;
title: string;
description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => unknown | Promise<unknown>;
disabled?: boolean;
};
export default function ConfirmAction({
trigger,
title,
description,
confirmLabel = "حذف",
cancelLabel = "انصراف",
onConfirm,
disabled = false,
}: ConfirmActionProps) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
{trigger}
</AlertDialogTrigger>
<AlertDialogContent dir="rtl">
<AlertDialogHeader className="text-right">
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="leading-7">{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:justify-start">
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={disabled}
onClick={() => void onConfirm()}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
import ConfirmAction from "@/components/ConfirmAction";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -33,15 +34,21 @@ function NotificationItem({
)}
>
<div className="flex items-start justify-between gap-3">
<ConfirmAction
title="حذف اعلان"
description="آیا از حذف این اعلان مطمئن هستید؟"
onConfirm={() => onDelete(notification)}
trigger={
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
onClick={() => void onDelete(notification)}
>
<Trash2 className="h-4 w-4" />
</Button>
}
/>
<button
type="button"
onClick={() => void onOpen(notification)}

View File

@@ -18,6 +18,7 @@ import {
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import ConfirmAction from "@/components/ConfirmAction";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -342,16 +343,23 @@ export default function AdminBlogAssets({ postId }: Props) {
</a>
</Button>
) : null}
<ConfirmAction
title="حذف فایل"
description={`آیا از حذف فایل «${asset.title}» مطمئن هستید؟ لینک‌های استفاده‌شده از این فایل دیگر کار نخواهند کرد.`}
onConfirm={() => deleteAsset(asset.id)}
disabled={deletingId === asset.id}
trigger={
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => deleteAsset(asset.id)}
disabled={deletingId === asset.id}
aria-label="حذف فایل"
>
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
}
/>
</div>
<div className="min-w-0 flex-1 text-right">
<div className="flex flex-wrap items-center justify-end gap-2">

View File

@@ -3,6 +3,7 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
import ConfirmAction from "@/components/ConfirmAction";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
@@ -119,7 +120,6 @@ export default function AdminBlogCategories() {
};
const deleteCategory = async (category: Types.AdminCategorySchema) => {
if (!window.confirm(`دسته‌بندی «${category.name}» حذف شود؟`)) return;
try {
await api.deleteCategory(category.id);
toast({ title: "دسته‌بندی حذف شد", variant: "success" });
@@ -197,9 +197,16 @@ export default function AdminBlogCategories() {
<Edit3 className="h-3.5 w-3.5" />
</Button>
{canDelete ? (
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteCategory(category)}>
<ConfirmAction
title="حذف دسته‌بندی"
description={`آیا از حذف دسته‌بندی «${category.name}» مطمئن هستید؟`}
onConfirm={() => deleteCategory(category)}
trigger={
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
) : null}
</div>
</td>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import ConfirmAction from "@/components/ConfirmAction";
import Markdown from "@/components/Markdown";
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
import { useAuth } from "@/contexts/AuthContext";
@@ -384,10 +385,18 @@ export default function AdminBlogEditor({ postId }: Props) {
</div>
<div className="flex flex-wrap justify-end gap-2">
{post?.featured_image || post?.absolute_featured_image_url ? (
<Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
<ConfirmAction
title="حذف تصویر شاخص"
description="آیا از حذف تصویر شاخص این نوشته مطمئن هستید؟"
onConfirm={deleteFeaturedImage}
disabled={uploadingFeatured}
trigger={
<Button variant="outline" disabled={uploadingFeatured}>
<Trash2 className="ml-2 h-4 w-4" />
حذف تصویر
</Button>
}
/>
) : null}
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}

View File

@@ -3,6 +3,7 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
import ConfirmAction from "@/components/ConfirmAction";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
@@ -98,7 +99,6 @@ export default function AdminBlogTags() {
};
const deleteTag = async (tag: Types.AdminTagSchema) => {
if (!window.confirm(`برچسب «${tag.name}» حذف شود؟`)) return;
try {
await api.deleteTag(tag.id);
toast({ title: "برچسب حذف شد", variant: "success" });
@@ -172,9 +172,16 @@ export default function AdminBlogTags() {
<Edit3 className="h-3.5 w-3.5" />
</Button>
{canDelete ? (
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteTag(tag)}>
<ConfirmAction
title="حذف برچسب"
description={`آیا از حذف برچسب «${tag.name}» مطمئن هستید؟`}
onConfirm={() => deleteTag(tag)}
trigger={
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
) : null}
</div>
</td>

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit3, Plus, Trash2 } from "lucide-react";
import AdminDateTimeField from "@/components/AdminDateTimeField";
import ConfirmAction from "@/components/ConfirmAction";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -160,9 +161,17 @@ export default function AdminCoupons() {
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
<Edit3 className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" className="text-destructive hover:text-destructive" onClick={() => deleteMutation.mutate(item.id)} aria-label="حذف">
<ConfirmAction
title="حذف کد تخفیف"
description={`آیا از حذف کد «${item.code}» مطمئن هستید؟ این کد دیگر در لیست‌های عادی نمایش داده نمی‌شود.`}
onConfirm={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
trigger={
<Button size="icon" variant="outline" className="text-destructive hover:text-destructive" disabled={deleteMutation.isPending} aria-label="حذف">
<Trash2 className="h-4 w-4" />
</Button>
}
/>
</div>
</td>
</tr>

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ImagePlus, Trash2, Upload } from "lucide-react";
import AdminDateTimeField from "@/components/AdminDateTimeField";
import ConfirmAction from "@/components/ConfirmAction";
import Markdown from "@/components/Markdown";
import MarkdownEditor from "@/components/MarkdownEditor";
import ProgressiveImage from "@/components/ProgressiveImage";
@@ -354,9 +355,17 @@ export default function AdminEventForm({ mode }: { mode: Mode }) {
</button>
<div className="flex items-center justify-between gap-2 p-3 text-sm">
<span className="truncate">{item.title}</span>
<Button size="icon" variant="ghost" className="text-destructive" onClick={() => galleryDeleteMutation.mutate(item.id)}>
<ConfirmAction
title="حذف تصویر گالری"
description={`آیا از حذف «${item.title}» از گالری رویداد مطمئن هستید؟`}
onConfirm={() => galleryDeleteMutation.mutate(item.id)}
disabled={galleryDeleteMutation.isPending}
trigger={
<Button size="icon" variant="ghost" className="text-destructive" disabled={galleryDeleteMutation.isPending}>
<Trash2 className="h-4 w-4" />
</Button>
}
/>
</div>
</div>
))}

View File

@@ -3,6 +3,7 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit3, Plus, Trash2 } from "lucide-react";
import ConfirmAction from "@/components/ConfirmAction";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -150,15 +151,23 @@ export default function AdminMetaOptions({ kind }: { kind: Kind }) {
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
<Edit3 className="h-4 w-4" />
</Button>
<ConfirmAction
title="حذف مورد"
description={`آیا از حذف «${item.label}» مطمئن هستید؟ این عملیات رکورد را از لیست‌های عادی حذف می‌کند.`}
onConfirm={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
trigger={
<Button
size="icon"
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
aria-label="حذف"
>
<Trash2 className="h-4 w-4" />
</Button>
}
/>
</div>
</td>
</tr>

View File

@@ -19,6 +19,7 @@ import {
} from "lucide-react";
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
import BlogThumbnail from "@/components/BlogThumbnail";
import ConfirmAction from "@/components/ConfirmAction";
import Markdown from "@/components/Markdown";
import { Helmet } from "@/lib/helmet";
import { Link, Navigate } from "@/lib/router";
@@ -395,19 +396,23 @@ export default function Profile() {
</span>
) : null}
{me?.profile_picture ? (
<ConfirmAction
title="حذف تصویر پروفایل"
description="آیا از حذف تصویر پروفایل خود مطمئن هستید؟"
onConfirm={onDeletePicture}
disabled={uploading}
trigger={
<span
role="button"
tabIndex={0}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onDeletePicture();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
void onDeletePicture();
}
}}
className="absolute bottom-1 right-1 flex h-9 w-9 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg"
@@ -415,6 +420,8 @@ export default function Profile() {
>
<Trash2 className="h-4 w-4" />
</span>
}
/>
) : null}
</button>
);