@mytec: iter9.1 ready for test
This commit is contained in:
@@ -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>
|
||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
Radius: {settings.radius} km
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
<div className="space-y-3">
|
||||
<NumberInput
|
||||
label="Radius"
|
||||
value={settings.radius}
|
||||
onChange={(v) =>
|
||||
useCoverageStore.getState().updateSettings({ radius: v })
|
||||
}
|
||||
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"
|
||||
unit="km"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
Resolution: {settings.resolution}m
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
<NumberInput
|
||||
label="Resolution"
|
||||
value={settings.resolution}
|
||||
onChange={(v) =>
|
||||
useCoverageStore.getState().updateSettings({ resolution: v })
|
||||
}
|
||||
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"
|
||||
unit="m"
|
||||
/>
|
||||
</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}
|
||||
<NumberInput
|
||||
label="Min Signal"
|
||||
value={settings.rsrpThreshold}
|
||||
onChange={(e) =>
|
||||
useCoverageStore
|
||||
.getState()
|
||||
.updateSettings({
|
||||
rsrpThreshold: Number(e.target.value),
|
||||
})
|
||||
onChange={(v) =>
|
||||
useCoverageStore.getState().updateSettings({ rsrpThreshold: v })
|
||||
}
|
||||
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>
|
||||
<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),
|
||||
})
|
||||
<NumberInput
|
||||
label="Heatmap Opacity"
|
||||
value={Math.round(settings.heatmapOpacity * 100)}
|
||||
onChange={(v) =>
|
||||
useCoverageStore.getState().updateSettings({ heatmapOpacity: v / 100 })
|
||||
}
|
||||
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>
|
||||
<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,28 +431,22 @@ 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"
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user