@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

@@ -18,6 +18,7 @@ import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
import ToastContainer from '@/components/ui/Toast.tsx'; import ToastContainer from '@/components/ui/Toast.tsx';
import ThemeToggle from '@/components/ui/ThemeToggle.tsx'; import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
import Button from '@/components/ui/Button.tsx'; import Button from '@/components/ui/Button.tsx';
import NumberInput from '@/components/ui/NumberInput.tsx';
const calculator = new RFCalculator(); const calculator = new RFCalculator();
@@ -363,86 +364,54 @@ export default function App() {
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text"> <h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Coverage Settings Coverage Settings
</h3> </h3>
<div className="space-y-2"> <div className="space-y-3">
<div> <NumberInput
<label className="text-xs text-gray-500 dark:text-dark-muted"> label="Radius"
Radius: {settings.radius} km value={settings.radius}
</label> onChange={(v) =>
<input useCoverageStore.getState().updateSettings({ radius: v })
type="range" }
min={1} min={1}
max={100} max={100}
step={5} step={5}
value={settings.radius} unit="km"
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({ radius: Number(e.target.value) })
}
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
/> />
</div> <NumberInput
<div> label="Resolution"
<label className="text-xs text-gray-500 dark:text-dark-muted"> value={settings.resolution}
Resolution: {settings.resolution}m onChange={(v) =>
</label> useCoverageStore.getState().updateSettings({ resolution: v })
<input }
type="range"
min={50} min={50}
max={500} max={500}
step={50} step={50}
value={settings.resolution} unit="m"
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({ resolution: Number(e.target.value) })
}
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
/> />
</div> <NumberInput
<div> label="Min Signal"
<label className="text-xs text-gray-500 dark:text-dark-muted">
Min Signal: {settings.rsrpThreshold} dBm
</label>
<input
type="range"
min={-140}
max={-70}
step={5}
value={settings.rsrpThreshold} value={settings.rsrpThreshold}
onChange={(e) => onChange={(v) =>
useCoverageStore useCoverageStore.getState().updateSettings({ rsrpThreshold: v })
.getState()
.updateSettings({
rsrpThreshold: Number(e.target.value),
})
} }
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600" min={-140}
max={-50}
step={5}
unit="dBm"
/> />
</div> <NumberInput
<div> label="Heatmap Opacity"
<label className="text-xs text-gray-500 dark:text-dark-muted"> value={Math.round(settings.heatmapOpacity * 100)}
Heatmap Opacity: {Math.round(settings.heatmapOpacity * 100)}% onChange={(v) =>
</label> useCoverageStore.getState().updateSettings({ heatmapOpacity: v / 100 })
<input
type="range"
min={0.3}
max={1.0}
step={0.1}
value={settings.heatmapOpacity}
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({
heatmapOpacity: Number(e.target.value),
})
} }
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600" min={30}
max={100}
step={5}
unit="%"
/> />
</div>
<div> <div>
<label className="text-xs text-gray-500 dark:text-dark-muted"> <label className="text-sm font-medium text-gray-700 dark:text-dark-text">
Heatmap Quality: {settings.heatmapRadius}m Heatmap Quality
</label> </label>
<select <select
value={settings.heatmapRadius} value={settings.heatmapRadius}
@@ -453,7 +422,7 @@ export default function App() {
heatmapRadius: Number(e.target.value), heatmapRadius: Number(e.target.value),
}) })
} }
className="w-full mt-1 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-200 dark:border-dark-border rounded text-gray-700 dark:text-dark-text" className="w-full mt-1 px-2 py-1.5 text-sm bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded-md text-gray-700 dark:text-dark-text"
> >
<option value={200}>200m Fast</option> <option value={200}>200m Fast</option>
<option value={400}>400m Balanced</option> <option value={400}>400m Balanced</option>
@@ -462,28 +431,22 @@ export default function App() {
</select> </select>
{settings.heatmapRadius >= 600 && settings.resolution > 200 && ( {settings.heatmapRadius >= 600 && settings.resolution > 200 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400"> <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
Wide radius works best with 200m resolution. Current: {settings.resolution}m Wide radius works best with fine resolution (200m or less). Current: {settings.resolution}m
</p> </p>
)} )}
</div> </div>
<div> <NumberInput
<label className="text-xs text-gray-500 dark:text-dark-muted"> label="Terrain Opacity"
Terrain Opacity: {Math.round(terrainOpacity * 100)}% value={Math.round(terrainOpacity * 100)}
{!showTerrain && ' (disabled)'} onChange={(v) => setTerrainOpacity(v / 100)}
</label> min={10}
<input max={100}
type="range" step={5}
min={0.1} unit="%"
max={1.0} hint={!showTerrain ? 'Enable terrain overlay first' : undefined}
step={0.1}
value={terrainOpacity}
onChange={(e) => setTerrainOpacity(Number(e.target.value))}
disabled={!showTerrain}
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600 disabled:opacity-40"
/> />
</div> </div>
</div> </div>
</div>
{/* Map Tools */} {/* Map Tools */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3"> <div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">

View File

@@ -3,6 +3,7 @@ import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts'; import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx'; import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx'; import Button from '@/components/ui/Button.tsx';
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
import BatchEdit from './BatchEdit.tsx'; import BatchEdit from './BatchEdit.tsx';
interface SiteListProps { interface SiteListProps {
@@ -58,10 +59,49 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
setTimeout(() => setFlashIds(new Set()), 700); 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); 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; const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
@@ -148,7 +188,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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" 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" title="Delete site"
@@ -245,6 +285,18 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
})} })}
</div> </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> </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'; type ToastType = 'success' | 'error' | 'info' | 'warning';
interface ToastAction {
label: string;
onClick: () => void;
}
interface ToastMessage { interface ToastMessage {
id: number; id: number;
text: string; text: string;
type: ToastType; type: ToastType;
action?: ToastAction;
duration: number;
} }
interface ToastStore { interface ToastStore {
toasts: ToastMessage[]; toasts: ToastMessage[];
addToast: (text: string, type?: ToastType) => void; addToast: (text: string, type?: ToastType, options?: { action?: ToastAction; duration?: number }) => void;
removeToast: (id: number) => void; removeToast: (id: number) => void;
} }
@@ -19,16 +26,17 @@ let nextId = 0;
export const useToastStore = create<ToastStore>((set) => ({ export const useToastStore = create<ToastStore>((set) => ({
toasts: [], toasts: [],
addToast: (text, type = 'info') => { addToast: (text, type = 'info', options) => {
const id = nextId++; const id = nextId++;
const duration = options?.duration ?? 4000;
set((state) => ({ set((state) => ({
toasts: [...state.toasts, { id, text, type }], toasts: [...state.toasts, { id, text, type, action: options?.action, duration }],
})); }));
setTimeout(() => { setTimeout(() => {
set((state) => ({ set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id), toasts: state.toasts.filter((t) => t.id !== id),
})); }));
}, 4000); }, duration);
}, },
removeToast: (id) => removeToast: (id) =>
set((state) => ({ set((state) => ({
@@ -65,9 +73,20 @@ function ToastItem({ toast }: { toast: ToastMessage }) {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex-1">{toast.text}</span> <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 <button
onClick={handleClose} onClick={handleClose}
className="text-white/80 hover:text-white" className="text-white/80 hover:text-white ml-1"
> >
× ×
</button> </button>

View File

@@ -85,16 +85,41 @@ export function useKeyboardShortcuts({
case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle
window.dispatchEvent(new CustomEvent('rfcp:fit-bounds')); window.dispatchEvent(new CustomEvent('rfcp:fit-bounds'));
return; return;
case 'delete': // Delete key: delete selected sites case 'delete': // Delete key: delete selected site with undo
case 'backspace': case 'backspace':
// Only if a site is selected (not batch)
{ {
const selectedId = useSitesStore.getState().selectedSiteId; const selectedId = useSitesStore.getState().selectedSiteId;
if (selectedId) { if (selectedId) {
const site = useSitesStore.getState().sites.find(s => s.id === selectedId); const site = useSitesStore.getState().sites.find(s => s.id === selectedId);
if (site) { if (site) {
// Snapshot for undo
const snapshot = { ...site };
useSitesStore.getState().deleteSite(selectedId); useSitesStore.getState().deleteSite(selectedId);
useToastStore.getState().addToast(`"${site.name}" deleted`, 'info'); useToastStore.getState().addToast(`"${site.name}" deleted`, 'info', {
duration: 10000,
action: {
label: 'Undo',
onClick: async () => {
await useSitesStore.getState().addSite({
name: snapshot.name,
lat: snapshot.lat,
lon: snapshot.lon,
height: snapshot.height,
power: snapshot.power,
gain: snapshot.gain,
frequency: snapshot.frequency,
antennaType: snapshot.antennaType,
azimuth: snapshot.azimuth,
beamwidth: snapshot.beamwidth,
color: snapshot.color,
visible: snapshot.visible,
notes: snapshot.notes,
equipment: snapshot.equipment,
});
useToastStore.getState().addToast(`"${snapshot.name}" restored`, 'success');
},
},
});
} }
} }
} }