@mytec: iter9.1 ready for test
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
||||
import BatchEdit from './BatchEdit.tsx';
|
||||
|
||||
interface SiteListProps {
|
||||
@@ -58,10 +59,49 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
setTimeout(() => setFlashIds(new Set()), 700);
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
// Delete confirmation dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const handleDeleteConfirmed = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
const { id, name } = deleteTarget;
|
||||
|
||||
// Snapshot the site data before deleting (for undo)
|
||||
const siteData = sites.find((s) => s.id === id);
|
||||
setDeleteTarget(null);
|
||||
|
||||
await deleteSite(id);
|
||||
addToast(`"${name}" deleted`, 'info');
|
||||
};
|
||||
|
||||
// Toast with undo action
|
||||
if (siteData) {
|
||||
addToast(`"${name}" deleted`, 'info', {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: async () => {
|
||||
// Restore the deleted site
|
||||
await useSitesStore.getState().addSite({
|
||||
name: siteData.name,
|
||||
lat: siteData.lat,
|
||||
lon: siteData.lon,
|
||||
height: siteData.height,
|
||||
power: siteData.power,
|
||||
gain: siteData.gain,
|
||||
frequency: siteData.frequency,
|
||||
antennaType: siteData.antennaType,
|
||||
azimuth: siteData.azimuth,
|
||||
beamwidth: siteData.beamwidth,
|
||||
color: siteData.color,
|
||||
visible: siteData.visible,
|
||||
notes: siteData.notes,
|
||||
equipment: siteData.equipment,
|
||||
});
|
||||
addToast(`"${name}" restored`, 'success');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [deleteTarget, sites, deleteSite, addToast]);
|
||||
|
||||
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
||||
|
||||
@@ -148,7 +188,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(site.id, site.name);
|
||||
setDeleteTarget({ id: site.id, name: site.name });
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
title="Delete site"
|
||||
@@ -245,6 +285,18 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
title="Delete Site?"
|
||||
message={`Are you sure you want to delete "${deleteTarget.name}"? This action can be undone for 10 seconds.`}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
danger
|
||||
onConfirm={handleDeleteConfirmed}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
81
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
81
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
danger = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus the cancel button (safer default)
|
||||
confirmRef.current?.focus();
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onCancel, onConfirm]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9500] bg-black/50 flex items-center justify-center"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-dark-surface rounded-lg shadow-xl p-5 max-w-sm mx-4 border border-gray-200 dark:border-dark-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-dark-text mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-dark-muted mb-5">
|
||||
{message}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm rounded-md bg-gray-100 dark:bg-dark-border text-gray-700 dark:text-dark-text
|
||||
hover:bg-gray-200 dark:hover:bg-dark-muted transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm rounded-md text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
${danger
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,22 @@ import { create } from 'zustand';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface ToastAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: ToastType;
|
||||
action?: ToastAction;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: ToastMessage[];
|
||||
addToast: (text: string, type?: ToastType) => void;
|
||||
addToast: (text: string, type?: ToastType, options?: { action?: ToastAction; duration?: number }) => void;
|
||||
removeToast: (id: number) => void;
|
||||
}
|
||||
|
||||
@@ -19,16 +26,17 @@ let nextId = 0;
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (text, type = 'info') => {
|
||||
addToast: (text, type = 'info', options) => {
|
||||
const id = nextId++;
|
||||
const duration = options?.duration ?? 4000;
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { id, text, type }],
|
||||
toasts: [...state.toasts, { id, text, type, action: options?.action, duration }],
|
||||
}));
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, 4000);
|
||||
}, duration);
|
||||
},
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
@@ -65,9 +73,20 @@ function ToastItem({ toast }: { toast: ToastMessage }) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1">{toast.text}</span>
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
handleClose();
|
||||
}}
|
||||
className="px-2 py-0.5 text-xs font-semibold bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-white/80 hover:text-white"
|
||||
className="text-white/80 hover:text-white ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user