Sites ({sites.length})
+ {hasGroups && (
+
+ · {locationCount} location{locationCount !== 1 ? 's' : ''}
+
+ )}
- {/* Select All row (only when sites exist) */}
+ {/* Select All row */}
{sites.length > 0 && (
)}
- {/* Batch edit panel (appears when sites are selected) */}
+ {/* Batch edit panel */}
{selectedSiteIds.length > 0 && (
@@ -94,84 +219,27 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
No sites yet. Click on the map or use "+ Manual" to add one.
) : (
-
- {sites.map((site) => {
- const isBatchSelected = selectedSiteIds.includes(site.id);
- const isFlashing = flashIds.has(site.id);
+
+ {siteGroups.map((group) => {
+ if (group.sites.length === 1) {
+ // Single site — render normally
+ return renderSiteRow(group.sites[0], false);
+ }
+ // Multi-sector group
return (
-
selectSite(site.id)}
- >
- {/* Batch checkbox */}
-
toggleSiteSelection(site.id)}
- onClick={(e) => e.stopPropagation()}
- className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0"
- />
-
- {/* Color indicator */}
-
-
- {/* Info */}
-
-
- {site.name}
-
-
- {site.frequency} MHz · {site.power} dBm ·{' '}
- {site.antennaType === 'omni'
- ? 'Omni'
- : `Sector ${site.azimuth ?? 0}°`}
- {' '}· {site.height}m
-
-
-
- {/* Actions */}
-
-
-
-
+
+ {/* Group header */}
+
+
+ {group.label}
+
+
+ {group.sites.length} sectors
+
+ {/* Grouped site rows */}
+ {group.sites.map((site) => renderSiteRow(site, true))}
);
})}
diff --git a/frontend/src/components/ui/NumberInput.tsx b/frontend/src/components/ui/NumberInput.tsx
new file mode 100644
index 0000000..cbeef2a
--- /dev/null
+++ b/frontend/src/components/ui/NumberInput.tsx
@@ -0,0 +1,147 @@
+import { useCallback, useRef } from 'react';
+
+interface NumberInputProps {
+ label: string;
+ value: number;
+ onChange: (value: number) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+ unit?: string;
+ hint?: string;
+ showSlider?: boolean;
+}
+
+export default function NumberInput({
+ label,
+ value,
+ onChange,
+ min = 0,
+ max = 100,
+ step = 1,
+ unit = '',
+ hint,
+ showSlider = true,
+}: NumberInputProps) {
+ const holdTimer = useRef
| null>(null);
+
+ const clamp = useCallback(
+ (v: number) => Math.max(min, Math.min(max, v)),
+ [min, max]
+ );
+
+ const increment = useCallback(() => {
+ onChange(clamp(value + step));
+ }, [value, step, onChange, clamp]);
+
+ const decrement = useCallback(() => {
+ onChange(clamp(value - step));
+ }, [value, step, onChange, clamp]);
+
+ // Hold-to-repeat for arrow buttons
+ const startHold = useCallback(
+ (fn: () => void) => {
+ fn();
+ holdTimer.current = setInterval(fn, 120);
+ },
+ []
+ );
+
+ const stopHold = useCallback(() => {
+ if (holdTimer.current) {
+ clearInterval(holdTimer.current);
+ holdTimer.current = null;
+ }
+ }, []);
+
+ const handleTextChange = (e: React.ChangeEvent) => {
+ const raw = e.target.value;
+ if (raw === '' || raw === '-') return;
+ const v = Number(raw);
+ if (!isNaN(v)) {
+ onChange(clamp(v));
+ }
+ };
+
+ // Format value for display — avoid long decimals
+ const displayValue = Number.isInteger(step) ? value : Number(value.toFixed(1));
+
+ return (
+
+ {/* Label + value badge */}
+
+
+
+ {displayValue} {unit}
+
+
+
+ {/* Text input + arrow buttons */}
+
+
+
+
+
+
+
+
+ {/* Slider for visual feedback */}
+ {showSlider && (
+
onChange(Number(e.target.value))}
+ min={min}
+ max={max}
+ step={step}
+ className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
+ />
+ )}
+
+ {/* Min/max labels */}
+ {showSlider && (
+
+ {min}{unit}
+ {max}{unit}
+
+ )}
+
+ {hint &&
{hint}
}
+
+ );
+}
diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts
index 3594221..6d46c9a 100644
--- a/frontend/src/hooks/useKeyboardShortcuts.ts
+++ b/frontend/src/hooks/useKeyboardShortcuts.ts
@@ -1,61 +1,121 @@
import { useEffect } from 'react';
import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
+import { useSettingsStore } from '@/store/settings.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
interface ShortcutHandlers {
onCalculate: () => void;
onCloseForm: () => void;
+ onShowShortcuts?: () => void;
}
-export function useKeyboardShortcuts({ onCalculate, onCloseForm }: ShortcutHandlers) {
+function isInputActive(): boolean {
+ const el = document.activeElement;
+ if (!el) return false;
+ const tag = el.tagName;
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
+}
+
+export function useKeyboardShortcuts({
+ onCalculate,
+ onCloseForm,
+ onShowShortcuts,
+}: ShortcutHandlers) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- // Ignore if typing in input or textarea
- if (
- e.target instanceof HTMLInputElement ||
- e.target instanceof HTMLTextAreaElement ||
- e.target instanceof HTMLSelectElement
- ) {
- return;
- }
-
const isMac = navigator.platform.toLowerCase().includes('mac');
const modKey = isMac ? e.metaKey : e.ctrlKey;
- if (modKey) {
- switch (e.key.toLowerCase()) {
- case 'enter': // Ctrl/Cmd+Enter: Calculate coverage
- e.preventDefault();
- onCalculate();
- break;
+ // === Modifier shortcuts (work even in inputs) ===
+ if (modKey && e.key === 'Enter') {
+ e.preventDefault();
+ onCalculate();
+ return;
+ }
- case 'n': // Ctrl/Cmd+N: New site (enter placement mode)
+ // Escape always works
+ if (e.key === 'Escape') {
+ useSitesStore.getState().selectSite(null);
+ useSitesStore.getState().setPlacingMode(false);
+ onCloseForm();
+ return;
+ }
+
+ // === Skip remaining shortcuts if typing in an input ===
+ if (isInputActive()) return;
+
+ // Shift combos (no browser conflicts)
+ if (e.shiftKey && !modKey && !e.altKey) {
+ switch (e.key.toUpperCase()) {
+ case 'S': // Shift+S: New site (place mode)
e.preventDefault();
useSitesStore.getState().setPlacingMode(true);
useToastStore.getState().addToast('Click on map to place new site', 'info');
- break;
+ return;
+ case 'C': // Shift+C: Clear coverage
+ e.preventDefault();
+ useCoverageStore.getState().clearCoverage();
+ useToastStore.getState().addToast('Coverage cleared', 'info');
+ return;
}
}
- // Non-modifier shortcuts
- switch (e.key) {
- case 'Escape': // Escape: Cancel/close
- useSitesStore.getState().selectSite(null);
- useSitesStore.getState().setPlacingMode(false);
- onCloseForm();
- break;
-
- case 'h': // H: Toggle heatmap
- case 'H':
- if (!e.ctrlKey && !e.metaKey && !e.altKey) {
+ // Single letter shortcuts (no modifiers)
+ if (!modKey && !e.altKey && !e.shiftKey) {
+ switch (e.key.toLowerCase()) {
+ case 'h': // H: Toggle heatmap
useCoverageStore.getState().toggleHeatmap();
- }
- break;
+ return;
+ case 'g': // G: Toggle grid
+ useSettingsStore.getState().setShowGrid(
+ !useSettingsStore.getState().showGrid
+ );
+ return;
+ case 't': // T: Toggle terrain
+ useSettingsStore.getState().setShowElevationOverlay(
+ !useSettingsStore.getState().showElevationOverlay
+ );
+ return;
+ case 'r': // R: Toggle ruler / measurement
+ useSettingsStore.getState().setMeasurementMode(
+ !useSettingsStore.getState().measurementMode
+ );
+ return;
+ 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 '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) {
+ useSitesStore.getState().deleteSite(selectedId);
+ useToastStore.getState().addToast(`"${site.name}" deleted`, 'info');
+ }
+ }
+ }
+ return;
+ }
+
+ // ? key for help (not shift on some layouts)
+ if (e.key === '?') {
+ onShowShortcuts?.();
+ return;
+ }
+ }
+
+ // ? with shift (most layouts: shift+/)
+ if (e.shiftKey && !modKey && !e.altKey && e.key === '?') {
+ onShowShortcuts?.();
+ return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [onCalculate, onCloseForm]);
+ }, [onCalculate, onCloseForm, onShowShortcuts]);
}
diff --git a/frontend/src/store/sites.ts b/frontend/src/store/sites.ts
index 4f82db7..4a99474 100644
--- a/frontend/src/store/sites.ts
+++ b/frontend/src/store/sites.ts
@@ -38,6 +38,8 @@ interface SitesState {
clearSelection: () => void;
batchUpdateHeight: (adjustment: number) => Promise;
batchSetHeight: (height: number) => Promise;
+ batchAdjustAzimuth: (delta: number) => Promise;
+ batchSetAzimuth: (azimuth: number) => Promise;
}
export const useSitesStore = create((set, get) => ({
@@ -276,4 +278,62 @@ export const useSitesStore = create((set, get) => ({
set({ sites: updatedSites });
},
+
+ batchAdjustAzimuth: async (delta: number) => {
+ const { sites, selectedSiteIds } = get();
+ const selectedSet = new Set(selectedSiteIds);
+ const now = new Date();
+
+ const updatedSites = sites.map((site) => {
+ if (!selectedSet.has(site.id)) return site;
+ const current = site.azimuth ?? 0;
+ return {
+ ...site,
+ azimuth: ((current + delta) % 360 + 360) % 360,
+ updatedAt: now,
+ };
+ });
+
+ const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
+ for (const site of toUpdate) {
+ await db.sites.put({
+ id: site.id,
+ data: JSON.stringify(site),
+ createdAt: site.createdAt.getTime(),
+ updatedAt: now.getTime(),
+ });
+ }
+
+ set({ sites: updatedSites });
+ useCoverageStore.getState().clearCoverage();
+ },
+
+ batchSetAzimuth: async (azimuth: number) => {
+ const { sites, selectedSiteIds } = get();
+ const selectedSet = new Set(selectedSiteIds);
+ const clamped = ((azimuth % 360) + 360) % 360;
+ const now = new Date();
+
+ const updatedSites = sites.map((site) => {
+ if (!selectedSet.has(site.id)) return site;
+ return {
+ ...site,
+ azimuth: clamped,
+ updatedAt: now,
+ };
+ });
+
+ const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
+ for (const site of toUpdate) {
+ await db.sites.put({
+ id: site.id,
+ data: JSON.stringify(site),
+ createdAt: site.createdAt.getTime(),
+ updatedAt: now.getTime(),
+ });
+ }
+
+ set({ sites: updatedSites });
+ useCoverageStore.getState().clearCoverage();
+ },
}));