feat(ui): replace all native confirm() dialogs with styled ConfirmDialog
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
Add ConfirmDialogProvider + useConfirm hook (Promise-based API) so every window.confirm() in the app is replaced with a consistent themed modal. Replaced in 11 files (16 call-sites): - Settings: update panel action - Templates: delete template - Storages: delete storage - SSH Keys: delete key - Nodes: delete server - NodeNetworks: delete interface, apply pending, revert pending - HAResources: remove HA resource - SDNManager: apply SDN, delete zone/vnet, delete subnet - IPAM: release allocation, delete pool, cleanup orphans Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { router } from '@/router';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { ConfirmDialogProvider } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -26,8 +27,10 @@ export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delay={200}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<ConfirmDialogProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</ConfirmDialogProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
73
frontend/src/components/shared/ConfirmDialog.tsx
Normal file
73
frontend/src/components/shared/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription,
|
||||
DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
open: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type ConfirmFn = (message: string, options?: ConfirmOptions) => Promise<boolean>;
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn | null>(null);
|
||||
|
||||
export function ConfirmDialogProvider({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<ConfirmState>({ open: false, message: '' });
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirm: ConfirmFn = useCallback((message, options = {}) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setState({ open: true, message, ...options });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = (result: boolean) => {
|
||||
setState((s) => ({ ...s, open: false }));
|
||||
resolveRef.current?.(result);
|
||||
resolveRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<Dialog open={state.open} onOpenChange={(open) => { if (!open) handleClose(false); }}>
|
||||
<DialogContent showCloseButton={false} className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{state.title ?? t('common.confirm')}</DialogTitle>
|
||||
<DialogDescription>{state.message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="border-t-0 bg-transparent pt-2">
|
||||
<Button variant="outline" onClick={() => handleClose(false)}>
|
||||
{state.cancelLabel ?? t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={state.variant === 'destructive' ? 'destructive' : 'default'}
|
||||
onClick={() => handleClose(true)}
|
||||
>
|
||||
{state.confirmLabel ?? t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm(): ConfirmFn {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) throw new Error('useConfirm must be used within ConfirmDialogProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/hooks/use-backups';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const STORAGE_TYPES = ['dir', 'nfs', 'cifs', 'pbs'];
|
||||
|
||||
@@ -180,6 +181,7 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
|
||||
export default function StoragesTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: servers = [] } = useServers();
|
||||
const [serverId, setServerId] = useState(0);
|
||||
|
||||
@@ -197,8 +199,8 @@ export default function StoragesTab() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<StorageRow | null>(null);
|
||||
|
||||
const handleDelete = (s: string) => {
|
||||
if (!confirm(`${t('storages.delete_confirm')} "${s}"?`)) return;
|
||||
const handleDelete = async (s: string) => {
|
||||
if (!await confirm(`${t('storages.delete_confirm')} "${s}"?`)) return;
|
||||
deleteStorage.mutate(s, {
|
||||
onSuccess: () => toast.success(t('storages.deleted')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
} from '@/hooks/use-ipam';
|
||||
import type { IPAMAllocation } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const STATUSES = ['allocated', 'reserved', 'available', 'conflict'] as const;
|
||||
const RESOURCE_TYPES = ['vm', 'lxc', 'physical', 'service', 'reserved'] as const;
|
||||
|
||||
export default function IPAMAllocationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [networkId, setNetworkId] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -48,8 +50,8 @@ export default function IPAMAllocationsPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleDelete = (a: IPAMAllocation) => {
|
||||
if (!confirm(t('ipam.delete_allocation_confirm', { ip: a.ip_address }))) return;
|
||||
const handleDelete = async (a: IPAMAllocation) => {
|
||||
if (!await confirm(t('ipam.delete_allocation_confirm', { ip: a.ip_address }))) return;
|
||||
deleteAllocation.mutate(a.id, {
|
||||
onSuccess: () => toast.success(t('ipam.released')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/hooks/use-ipam';
|
||||
import type { IPAMNetwork, IPAMPool } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
export default function IPAMNetworkDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -223,6 +224,7 @@ const emptyPool = { name: '', pool_type: 'static', range_start: '', range_end: '
|
||||
|
||||
function PoolsCard({ networkId, pools }: { networkId: number; pools: IPAMPool[] }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const createPool = useCreatePool();
|
||||
const updatePool = useUpdatePool();
|
||||
const deletePool = useDeletePool();
|
||||
@@ -259,8 +261,8 @@ function PoolsCard({ networkId, pools }: { networkId: number; pools: IPAMPool[]
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (p: IPAMPool) => {
|
||||
if (!confirm(t('ipam.delete_pool_confirm', { name: p.name }))) return;
|
||||
const handleDelete = async (p: IPAMPool) => {
|
||||
if (!await confirm(t('ipam.delete_pool_confirm', { name: p.name }))) return;
|
||||
deletePool.mutate(p.id, {
|
||||
onSuccess: () => toast.success(t('ipam.pool_deleted')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useCleanupOrphans, useLinkAllocations, useAutoAllocate, useNextAvailableIP, useIPHistoryLookup,
|
||||
} from '@/hooks/use-ipam';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
export default function IPAMToolsPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -174,12 +175,13 @@ function ConflictsCard() {
|
||||
|
||||
function OrphansCard() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data, isLoading } = useIPAMOrphans();
|
||||
const cleanup = useCleanupOrphans();
|
||||
const orphans = data?.orphans ?? [];
|
||||
|
||||
const handleCleanup = () => {
|
||||
if (!confirm(t('ipam.cleanup_confirm'))) return;
|
||||
const handleCleanup = async () => {
|
||||
if (!await confirm(t('ipam.cleanup_confirm'))) return;
|
||||
cleanup.mutate(undefined, {
|
||||
onSuccess: (r) => toast.success(`${t('ipam.released')}: ${r.released_count}`),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useHAStatus, useAddToHA, useRemoveFromHA } from '@/hooks/use-ha';
|
||||
import type { VirtualMachine } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const HA_STATES = ['started', 'stopped', 'enabled', 'disabled', 'ignored'];
|
||||
|
||||
@@ -35,6 +36,7 @@ const emptyAddForm: AddForm = {
|
||||
|
||||
export default function HAResources({ serverId, vms }: { serverId: number; vms: VirtualMachine[] }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data, isLoading, isFetching, refetch } = useHAStatus(serverId);
|
||||
const addHA = useAddToHA(serverId);
|
||||
const removeHA = useRemoveFromHA(serverId);
|
||||
@@ -74,8 +76,8 @@ export default function HAResources({ serverId, vms }: { serverId: number; vms:
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = (sid: string) => {
|
||||
if (!confirm(`${t('ha.remove_confirm')} "${sid}"?`)) return;
|
||||
const handleRemove = async (sid: string) => {
|
||||
if (!await confirm(`${t('ha.remove_confirm')} "${sid}"?`)) return;
|
||||
const [vmType, vmidStr] = sid.split(':');
|
||||
removeHA.mutate(
|
||||
{ vmType: vmType as 'vm' | 'ct', vmid: Number(vmidStr) },
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/hooks/use-nodes';
|
||||
import type { NodeNetworkInterface } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const IFACE_TYPES = ['bridge', 'bond', 'vlan', 'eth', 'alias', 'OVSBridge', 'OVSBond', 'OVSIntPort'];
|
||||
|
||||
@@ -91,6 +92,7 @@ function buildPayload(form: FormState): Record<string, unknown> {
|
||||
|
||||
export default function NodeNetworks({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [node, setNode] = useState(nodeNames[0] || '');
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = useNodeNetworks(serverId, node);
|
||||
@@ -144,24 +146,24 @@ export default function NodeNetworks({ serverId, nodeNames }: { serverId: number
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (iface: string) => {
|
||||
if (!confirm(`${t('common.confirm_delete')} "${iface}"?`)) return;
|
||||
const handleDelete = async (iface: string) => {
|
||||
if (!await confirm(`${t('common.confirm_delete')} "${iface}"?`)) return;
|
||||
deleteNet.mutate(iface, {
|
||||
onSuccess: () => toast.success(t('netif.deleted')),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!confirm(t('netif.apply_confirm'))) return;
|
||||
const handleApply = async () => {
|
||||
if (!await confirm(t('netif.apply_confirm'))) return;
|
||||
applyNet.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('netif.applied')),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevert = () => {
|
||||
if (!confirm(t('netif.revert_confirm'))) return;
|
||||
const handleRevert = async () => {
|
||||
if (!await confirm(t('netif.revert_confirm'))) return;
|
||||
revertNet.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('netif.reverted')),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestS
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import type { ProxmoxServerCreate } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
interface ServerFormData {
|
||||
name: string;
|
||||
@@ -198,6 +199,7 @@ function ServerFormDialog({
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: servers = [], isLoading } = useServers();
|
||||
const { data: vms = [] } = useVirtualMachines();
|
||||
const createServer = useCreateServer();
|
||||
@@ -228,8 +230,8 @@ export default function NodesPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`${t('common.confirm_delete')} "${name}"?`)) return;
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
if (!await confirm(`${t('common.confirm_delete')} "${name}"?`)) return;
|
||||
deleteServer.mutate(id, {
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/hooks/use-sdn';
|
||||
import type { SDNZone, SDNVNet } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
const ZONE_TYPES = ['simple', 'vlan', 'qinq', 'vxlan', 'evpn'];
|
||||
|
||||
@@ -210,6 +211,7 @@ const emptySubnet: SubnetForm = { subnet: '', gateway: '', snat: false, dnszonep
|
||||
|
||||
function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data, isLoading } = useSDNSubnets(serverId, vnet);
|
||||
const createSubnet = useCreateSDNSubnet(serverId);
|
||||
const deleteSubnet = useDeleteSDNSubnet(serverId);
|
||||
@@ -241,8 +243,8 @@ function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (subnetId: string, cidr: string) => {
|
||||
if (!confirm(`${t('sdn.delete_subnet')} "${cidr}"?`)) return;
|
||||
const remove = async (subnetId: string, cidr: string) => {
|
||||
if (!await confirm(`${t('sdn.delete_subnet')} "${cidr}"?`)) return;
|
||||
deleteSubnet.mutate({ vnet, subnetId }, {
|
||||
onSuccess: () => toast.success(t('sdn.subnet_deleted')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -321,6 +323,7 @@ function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
|
||||
|
||||
export default function SDNManager({ serverId }: { serverId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: status, isLoading: statusLoading, isFetching, refetch } = useSDNStatus(serverId);
|
||||
const available = status?.sdn_available ?? false;
|
||||
|
||||
@@ -339,20 +342,20 @@ export default function SDNManager({ serverId }: { serverId: number }) {
|
||||
const [editVNet, setEditVNet] = useState<SDNVNet | null>(null);
|
||||
const [expandedVNet, setExpandedVNet] = useState<string | null>(null);
|
||||
|
||||
const handleApply = () => {
|
||||
if (!confirm(t('sdn.apply_confirm'))) return;
|
||||
const handleApply = async () => {
|
||||
if (!await confirm(t('sdn.apply_confirm'))) return;
|
||||
applySDN.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('sdn.applied')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const removeZone = (zone: string) => {
|
||||
if (!confirm(`${t('sdn.delete_zone')} "${zone}"?`)) return;
|
||||
const removeZone = async (zone: string) => {
|
||||
if (!await confirm(`${t('sdn.delete_zone')} "${zone}"?`)) return;
|
||||
deleteZone.mutate(zone, { onSuccess: () => toast.success(t('sdn.zone_deleted')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
const removeVNet = (vnet: string) => {
|
||||
if (!confirm(`${t('sdn.delete_vnet')} "${vnet}"?`)) return;
|
||||
const removeVNet = async (vnet: string) => {
|
||||
if (!await confirm(`${t('sdn.delete_vnet')} "${vnet}"?`)) return;
|
||||
deleteVNet.mutate(vnet, { onSuccess: () => toast.success(t('sdn.vnet_deleted')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useUserSSHKeys, useAdminCreateUserSSHKey, useAdminUpdateUserSSHKey,
|
||||
useAdminDeleteUserSSHKey, useAdminDownloadUserPrivateKey,
|
||||
} from '@/hooks/use-ssh-keys';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
interface Props {
|
||||
/** When provided + admin=true, manage another user's keys via admin endpoints. */
|
||||
@@ -29,6 +30,7 @@ function getErrMsg(err: unknown): string {
|
||||
|
||||
export function SSHKeysManager({ userId = null, admin = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const myList = useMySSHKeys();
|
||||
const adminList = useUserSSHKeys(admin && userId ? userId : null);
|
||||
@@ -87,8 +89,8 @@ export function SSHKeysManager({ userId = null, admin = false }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = (id: number) => {
|
||||
if (!confirm(t('ssh_keys.confirm_delete'))) return;
|
||||
const onDelete = async (id: number) => {
|
||||
if (!await confirm(t('ssh_keys.confirm_delete'))) return;
|
||||
if (isAdminMode && userId) {
|
||||
adminDelete.mutate({ userId, keyId: id }, {
|
||||
onSuccess: () => toast.success(t('ssh_keys.deleted')),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/hooks/use-notifications';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { SSHKeysManager } from './SSHKeysManager';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -376,6 +377,7 @@ function NotificationPreferencesCard() {
|
||||
|
||||
function AboutTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: version } = useAppVersion();
|
||||
const checkUpdates = useCheckUpdates();
|
||||
const { data: repo } = useUpdateRepository();
|
||||
@@ -402,8 +404,8 @@ function AboutTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const handlePerform = () => {
|
||||
if (!confirm(t('settings.update_confirm'))) return;
|
||||
const handlePerform = async () => {
|
||||
if (!await confirm(t('settings.update_confirm'))) return;
|
||||
performUpdate.mutate(undefined, {
|
||||
onSuccess: (r) => r.success ? toast.success(r.message || t('settings.update_started')) : toast.error(r.error || 'Error'),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
|
||||
@@ -13,9 +13,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useTemplates, useTemplateGroups, useAutoImportTemplates, useDeleteTemplate } from '@/hooks/use-templates';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [search, setSearch] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string>('');
|
||||
const [serverFilter, setServerFilter] = useState<string>('');
|
||||
@@ -125,7 +127,7 @@ export default function TemplatesPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
|
||||
onClick={() => { if (confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
|
||||
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user