@mytec: iter10.5 ready for testing

This commit is contained in:
2026-01-30 19:41:47 +02:00
parent f0b62ada77
commit 8e35329622
2 changed files with 295 additions and 49 deletions

View File

@@ -11,6 +11,38 @@ interface SiteListProps {
onAddSite: () => void;
}
/** Greek letter prefix for sector visual identifiers */
const SECTOR_GREEK: Record<string, string> = {
Alpha: '\u03B1',
Beta: '\u03B2',
Gamma: '\u03B3',
Delta: '\u03B4',
Epsilon: '\u03B5',
Zeta: '\u03B6',
Eta: '\u03B7',
Theta: '\u03B8',
};
/** Get greek letter for a sector name, falling back to bullet */
function getGreekLetter(sectorName: string): string {
// Extract sector suffix — e.g. "Station-1-Alpha" → "Alpha"
const match = sectorName.match(/-(Alpha|Beta|Gamma|Delta|Epsilon|Zeta|Eta|Theta)$/i);
if (match) {
const key = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
return SECTOR_GREEK[key] || '\u2022';
}
return '\u2022';
}
/** Get short sector display name (without site prefix) */
function getSectorDisplayName(fullName: string, baseName: string): string {
// "Station-1-Alpha" with base "Station-1" → "Alpha"
if (fullName.startsWith(baseName + '-')) {
return fullName.slice(baseName.length + 1);
}
return fullName;
}
/** Group co-located sites (within ~10m = 0.0001°) */
function groupSites(sites: Site[]): { key: string; label: string; sites: Site[] }[] {
const groups = new Map<string, Site[]>();
@@ -53,6 +85,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const addToast = useToastStore((s) => s.addToast);
const [flashIds, setFlashIds] = useState<Set<string>>(new Set());
const [expandedSites, setExpandedSites] = useState<Set<string>>(new Set(['__all__']));
const triggerFlash = useCallback((ids: string[]) => {
setFlashIds(new Set(ids));
@@ -103,6 +136,27 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
}
}, [deleteTarget, sites, deleteSite, addToast]);
const toggleExpand = useCallback((groupKey: string) => {
setExpandedSites((prev) => {
const next = new Set(prev);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
return next;
});
}, []);
// Start with all groups expanded
const isExpanded = useCallback(
(groupKey: string) => {
// Default all expanded (if __all__ sentinel present or group key present)
return expandedSites.has('__all__') || expandedSites.has(groupKey);
},
[expandedSites]
);
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
// Group co-located sites
@@ -112,7 +166,8 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
// Count unique locations
const locationCount = siteGroups.length;
const renderSiteRow = (site: Site, isGrouped: boolean) => {
/** Render a single standalone site (not grouped) */
const renderStandaloneSite = (site: Site) => {
const isBatchSelected = selectedSiteIds.includes(site.id);
const isFlashing = flashIds.has(site.id);
@@ -121,10 +176,9 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
key={site.id}
className={`px-4 py-2 flex items-center gap-2.5 cursor-pointer
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20 border-l-[3px] border-l-purple-500' : 'border-l-[3px] border-l-transparent'}
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}
${isFlashing ? 'flash-update' : ''}
${isGrouped ? 'pl-8' : ''}`}
${isFlashing ? 'flash-update' : ''}`}
onClick={() => selectSite(site.id)}
>
{/* Batch checkbox */}
@@ -136,29 +190,22 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0"
/>
{/* Color indicator */}
<div
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
style={{ backgroundColor: site.color }}
/>
{/* Site icon */}
<span className="text-base flex-shrink-0" title="Site">
{'\uD83D\uDCCD'}
</span>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate flex items-center gap-1.5">
<div className="text-sm font-semibold text-gray-800 dark:text-dark-text truncate">
{site.name}
{/* Azimuth badge for grouped sectors */}
{isGrouped && site.antennaType === 'sector' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
{site.azimuth ?? 0}°
</span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-dark-muted">
{site.frequency} MHz · {site.power} dBm ·{' '}
{site.frequency} MHz {'\u00B7'} {site.power} dBm {'\u00B7'}{' '}
{site.antennaType === 'omni'
? 'Omni'
: `Sector ${site.azimuth ?? 0}°`}
{' '}· {site.height}m
: `Sector ${site.azimuth ?? 0}\u00B0`}
{' '}{'\u00B7'} {site.height}m
</div>
</div>
@@ -178,10 +225,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
onClick={async (e) => {
e.stopPropagation();
await cloneSector(site.id);
addToast(`Added sector to "${site.name}" (+120°)`, 'success');
addToast(`Added sector to "${site.name}" (+120\u00B0)`, 'success');
}}
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded min-h-[32px] flex items-center justify-center"
title="Add sector at same location (+120° azimuth)"
title="Add sector at same location (+120\u00B0 azimuth)"
>
+ Sector
</button>
@@ -193,7 +240,164 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
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"
>
×
{'\u00D7'}
</button>
</div>
</div>
);
};
/** Render a grouped site header (for multi-sector groups) */
const renderGroupHeader = (group: { key: string; label: string; sites: Site[] }) => {
const expanded = isExpanded(group.key);
const firstSite = group.sites[0];
return (
<div
className="px-4 py-2 bg-gray-50 dark:bg-dark-bg flex items-center gap-2.5 cursor-pointer
hover:bg-gray-100 dark:hover:bg-dark-border/30 transition-colors"
onClick={() => toggleExpand(group.key)}
>
{/* Expand/collapse chevron */}
<span
className={`text-xs text-gray-400 dark:text-dark-muted transition-transform flex-shrink-0 ${
expanded ? 'rotate-90' : ''
}`}
>
{'\u25B6'}
</span>
{/* Site icon */}
<span className="text-base flex-shrink-0" title="Site group">
{'\uD83D\uDCCD'}
</span>
{/* Group name + sector count */}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800 dark:text-dark-text truncate">
{group.label}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 font-medium flex-shrink-0">
{group.sites.length} sector{group.sites.length !== 1 ? 's' : ''}
</span>
</div>
{/* Group-level actions */}
<div className="flex gap-1 flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onEditSite(firstSite);
}}
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-h-[28px] flex items-center justify-center"
title="Edit site"
>
Edit
</button>
<button
onClick={async (e) => {
e.stopPropagation();
await cloneSector(firstSite.id);
addToast(`Added sector to "${group.label}" (+120\u00B0)`, 'success');
}}
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded min-h-[28px] flex items-center justify-center"
title="Add sector at same location (+120\u00B0 azimuth)"
>
+
</button>
<button
onClick={(e) => {
e.stopPropagation();
// Delete first site in group (user can delete others individually)
setDeleteTarget({ id: firstSite.id, name: firstSite.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-[28px] min-h-[28px] flex items-center justify-center"
title="Delete site"
>
{'\u00D7'}
</button>
</div>
</div>
);
};
/** Render a sector row within a group (indented, with tree connector) */
const renderSectorRow = (
site: Site,
baseName: string,
index: number,
total: number
) => {
const isBatchSelected = selectedSiteIds.includes(site.id);
const isFlashing = flashIds.has(site.id);
const isLast = index === total - 1;
const greekLetter = getGreekLetter(site.name);
const shortName = getSectorDisplayName(site.name, baseName);
return (
<div
key={site.id}
className={`flex items-center cursor-pointer
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}
${isFlashing ? 'flash-update' : ''}`}
onClick={() => selectSite(site.id)}
>
{/* Tree connector area */}
<div className="flex items-center pl-7 pr-1 self-stretch flex-shrink-0">
{/* Batch checkbox */}
<input
type="checkbox"
checked={isBatchSelected}
onChange={() => 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 mr-2"
/>
{/* Tree connector characters */}
<span className="text-xs text-gray-300 dark:text-dark-muted font-mono mr-1.5 flex-shrink-0 w-5 text-right">
{isLast ? '\u2514\u2500' : '\u251C\u2500'}
</span>
{/* Greek letter */}
<span
className="w-5 text-center text-sm font-semibold text-purple-500 dark:text-purple-400 flex-shrink-0"
title={shortName}
>
{greekLetter}
</span>
</div>
{/* Sector info */}
<div className="flex-1 min-w-0 py-1.5 flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-dark-text truncate">
{shortName}
</span>
<span className="text-xs text-gray-400 dark:text-dark-muted whitespace-nowrap">
{site.azimuth ?? 0}{'\u00B0'} {'\u00B7'} {site.frequency}MHz {'\u00B7'} {site.power}dBm
</span>
</div>
{/* Sector actions */}
<div className="flex gap-1 flex-shrink-0 pr-4">
<button
onClick={(e) => {
e.stopPropagation();
onEditSite(site);
}}
className="px-1.5 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded min-h-[28px] flex items-center justify-center"
title="Edit sector"
>
{'\u270E'}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({ id: site.id, name: site.name });
}}
className="px-1.5 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-h-[28px] flex items-center justify-center"
title="Delete sector"
>
{'\u00D7'}
</button>
</div>
</div>
@@ -208,7 +412,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
Sites ({sites.length})
{hasGroups && (
<span className="font-normal text-gray-400 dark:text-dark-muted ml-1">
· {locationCount} location{locationCount !== 1 ? 's' : ''}
{'\u00B7'} {locationCount} location{locationCount !== 1 ? 's' : ''}
</span>
)}
</h2>
@@ -262,24 +466,19 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-72 overflow-y-auto">
{siteGroups.map((group) => {
if (group.sites.length === 1) {
// Single site — render normally
return renderSiteRow(group.sites[0], false);
// Single site — render standalone
return renderStandaloneSite(group.sites[0]);
}
// Multi-sector group
// Multi-sector group with hierarchy
const expanded = isExpanded(group.key);
return (
<div key={group.key}>
{/* Group header */}
<div className="px-4 py-1.5 bg-gray-50 dark:bg-dark-bg flex items-center gap-2">
<span className="text-xs font-semibold text-gray-500 dark:text-dark-muted">
{group.label}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 font-medium">
{group.sites.length} sectors
</span>
</div>
{/* Grouped site rows */}
{group.sites.map((site) => renderSiteRow(site, true))}
{renderGroupHeader(group)}
{expanded &&
group.sites.map((site, i) =>
renderSectorRow(site, group.label, i, group.sites.length)
)}
</div>
);
})}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback, useRef, useState, useEffect } from 'react';
interface NumberInputProps {
label: string;
@@ -25,6 +25,18 @@ export default function NumberInput({
}: NumberInputProps) {
const holdTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// Local string state for text input — allows free typing without immediate validation
const [localValue, setLocalValue] = useState(String(value));
const [isFocused, setIsFocused] = useState(false);
// Sync local state when external value changes (slider, arrows, external)
useEffect(() => {
if (!isFocused) {
const display = Number.isInteger(step) ? String(value) : String(Number(value.toFixed(1)));
setLocalValue(display);
}
}, [value, step, isFocused]);
const clamp = useCallback(
(v: number) => Math.max(min, Math.min(max, v)),
[min, max]
@@ -54,16 +66,50 @@ export default function NumberInput({
}
}, []);
// Allow free typing — no validation on change
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === '' || raw === '-') return;
const v = Number(raw);
if (!isNaN(v)) {
onChange(clamp(v));
setLocalValue(e.target.value);
};
// Validate and commit value on blur or Enter
const commitValue = useCallback(() => {
const parsed = parseFloat(localValue);
if (isNaN(parsed) || localValue.trim() === '') {
// Reset to current valid value
const display = Number.isInteger(step) ? String(value) : String(Number(value.toFixed(1)));
setLocalValue(display);
return;
}
const clamped = clamp(parsed);
const display = Number.isInteger(step) ? String(clamped) : String(Number(clamped.toFixed(1)));
setLocalValue(display);
if (clamped !== value) {
onChange(clamped);
}
}, [localValue, value, step, clamp, onChange]);
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
commitValue();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
commitValue();
(e.target as HTMLInputElement).blur();
}
if (e.key === 'Escape') {
const display = Number.isInteger(step) ? String(value) : String(Number(value.toFixed(1)));
setLocalValue(display);
(e.target as HTMLInputElement).blur();
}
};
// Format value for display — avoid long decimals
// Format value for display in the badge (always shows committed value)
const displayValue = Number.isInteger(step) ? value : Number(value.toFixed(1));
return (
@@ -81,12 +127,13 @@ export default function NumberInput({
{/* Text input + arrow buttons */}
<div className="flex items-stretch gap-1">
<input
type="number"
value={displayValue}
type="text"
inputMode="numeric"
value={localValue}
onChange={handleTextChange}
min={min}
max={max}
step={step}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="flex-1 px-2 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text
rounded-md text-sm text-center font-mono
focus:outline-none focus:ring-2 focus:ring-blue-500