@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 ThemeToggle from '@/components/ui/ThemeToggle.tsx';
import Button from '@/components/ui/Button.tsx';
import NumberInput from '@/components/ui/NumberInput.tsx';
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">
Coverage Settings
</h3>
<div className="space-y-2">
<div className="space-y-3">
<NumberInput
label="Radius"
value={settings.radius}
onChange={(v) =>
useCoverageStore.getState().updateSettings({ radius: v })
}
min={1}
max={100}
step={5}
unit="km"
/>
<NumberInput
label="Resolution"
value={settings.resolution}
onChange={(v) =>
useCoverageStore.getState().updateSettings({ resolution: v })
}
min={50}
max={500}
step={50}
unit="m"
/>
<NumberInput
label="Min Signal"
value={settings.rsrpThreshold}
onChange={(v) =>
useCoverageStore.getState().updateSettings({ rsrpThreshold: v })
}
min={-140}
max={-50}
step={5}
unit="dBm"
/>
<NumberInput
label="Heatmap Opacity"
value={Math.round(settings.heatmapOpacity * 100)}
onChange={(v) =>
useCoverageStore.getState().updateSettings({ heatmapOpacity: v / 100 })
}
min={30}
max={100}
step={5}
unit="%"
/>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Radius: {settings.radius} km
</label>
<input
type="range"
min={1}
max={100}
step={5}
value={settings.radius}
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>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Resolution: {settings.resolution}m
</label>
<input
type="range"
min={50}
max={500}
step={50}
value={settings.resolution}
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>
<div>
<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}
onChange={(e) =>
useCoverageStore
.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"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Heatmap Opacity: {Math.round(settings.heatmapOpacity * 100)}%
</label>
<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"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Heatmap Quality: {settings.heatmapRadius}m
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
Heatmap Quality
</label>
<select
value={settings.heatmapRadius}
@@ -453,7 +422,7 @@ export default function App() {
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={400}>400m Balanced</option>
@@ -462,26 +431,20 @@ export default function App() {
</select>
{settings.heatmapRadius >= 600 && settings.resolution > 200 && (
<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>
)}
</div>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Terrain Opacity: {Math.round(terrainOpacity * 100)}%
{!showTerrain && ' (disabled)'}
</label>
<input
type="range"
min={0.1}
max={1.0}
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>
<NumberInput
label="Terrain Opacity"
value={Math.round(terrainOpacity * 100)}
onChange={(v) => setTerrainOpacity(v / 100)}
min={10}
max={100}
step={5}
unit="%"
hint={!showTerrain ? 'Enable terrain overlay first' : undefined}
/>
</div>
</div>

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>

View File

@@ -85,16 +85,41 @@ export function useKeyboardShortcuts({
case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle
window.dispatchEvent(new CustomEvent('rfcp:fit-bounds'));
return;
case 'delete': // Delete key: delete selected sites
case 'delete': // Delete key: delete selected site with undo
case 'backspace':
// Only if a site is selected (not batch)
{
const selectedId = useSitesStore.getState().selectedSiteId;
if (selectedId) {
const site = useSitesStore.getState().sites.find(s => s.id === selectedId);
if (site) {
// Snapshot for undo
const snapshot = { ...site };
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');
},
},
});
}
}
}