@mytec: iter9.1 ready for test

This commit is contained in:
2026-01-30 15:08:08 +02:00
parent 79d32c9d30
commit 7ad59df69d
5 changed files with 249 additions and 109 deletions

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>