rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
+451
View File
@@ -0,0 +1,451 @@
import { useCallback, useEffect, useState } from 'react';
import {
Antenna as AntennaIcon, Radio, Plus, Trash2, Star,
ChevronRight, ChevronDown, Edit2,
} from 'lucide-react';
import {
ListOperatingTree, SaveOperatingStation, DeleteOperatingStation,
SaveOperatingAntenna, DeleteOperatingAntenna,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
type Band = { band: string; is_default: boolean };
type Antenna = {
id: number;
station_id: number;
name: string;
sort_order: number;
bands: Band[];
};
type Station = {
id: number;
profile_id: number;
name: string;
tx_pwr?: number;
sort_order: number;
antennas?: Antenna[];
};
type Props = {
/** ADIF bands available to toggle, in display order (from ListsSettings). */
bands: string[];
/** External error sink — parent shows it next to the Save button. */
onError: (msg: string) => void;
};
export function OperatingPanel({ bands, onError }: Props) {
const [tree, setTree] = useState<Station[]>([]);
const [loading, setLoading] = useState(true);
// expanded keeps which stations show their antennas; everything open by
// default so the user sees the full setup at a glance.
const [expanded, setExpanded] = useState<Set<number>>(new Set());
// editingId tracks the row currently in edit mode. Use a string namespace
// to keep station ids and antenna ids in the same Set.
const [editing, setEditing] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
const t = await ListOperatingTree();
const list = (t ?? []) as Station[];
setTree(list);
setExpanded((prev) => {
if (prev.size > 0) return prev;
return new Set(list.map((s) => s.id));
});
} catch (e: any) {
onError(String(e?.message ?? e));
} finally {
setLoading(false);
}
}, [onError]);
useEffect(() => { void reload(); }, [reload]);
function toggleExpanded(id: number) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function addStation() {
try {
const created = await SaveOperatingStation({
id: 0, profile_id: 0, name: 'New rig', sort_order: tree.length,
} as any);
const c = created as Station;
setTree((prev) => [...prev, { ...c, antennas: [] }]);
setExpanded((prev) => new Set(prev).add(c.id));
setEditing(`station:${c.id}`);
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function updateStation(s: Station) {
try {
const saved = await SaveOperatingStation(s as any) as Station;
setTree((prev) => prev.map((x) => x.id === s.id ? { ...x, ...saved } : x));
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function removeStation(id: number) {
if (!confirm('Delete this rig and all its antennas?')) return;
try {
await DeleteOperatingStation(id);
setTree((prev) => prev.filter((s) => s.id !== id));
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function addAntenna(stationId: number) {
try {
const created = await SaveOperatingAntenna({
id: 0, station_id: stationId, name: 'New antenna', sort_order: 0, bands: [],
} as any) as Antenna;
setTree((prev) => prev.map((s) =>
s.id === stationId
? { ...s, antennas: [...(s.antennas ?? []), created] }
: s
));
setEditing(`antenna:${created.id}`);
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function updateAntenna(a: Antenna) {
try {
const saved = await SaveOperatingAntenna(a as any) as Antenna;
setTree((prev) => prev.map((s) => s.id === a.station_id
? {
...s,
antennas: (s.antennas ?? []).map((x) => x.id === a.id ? saved : x),
}
: {
// The save may have cleared is_default on antennas of OTHER
// stations (one default per band per profile). Refresh those
// by reloading the tree wholesale.
...s,
}
));
// Reload to pick up cross-station default flips.
void reload();
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function removeAntenna(stationId: number, antId: number) {
if (!confirm('Delete this antenna?')) return;
try {
await DeleteOperatingAntenna(antId);
setTree((prev) => prev.map((s) =>
s.id === stationId
? { ...s, antennas: (s.antennas ?? []).filter((a) => a.id !== antId) }
: s
));
} catch (e: any) { onError(String(e?.message ?? e)); }
}
if (loading) {
return <div className="text-xs text-muted-foreground italic">Loading</div>;
}
return (
<div className="space-y-3">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
Define your rigs (stations) and the antennas connected to each one.
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
the default antenna for that band when you change the band in the
entry strip, the matching rig + antenna auto-fill the MY_RIG and
MY_ANTENNA ADIF fields. Only one antenna can be the default per
band; setting one clears the previous default.
</div>
<div className="flex gap-2">
<Button size="sm" onClick={addStation}>
<Plus className="size-3.5" /> Add rig
</Button>
</div>
{tree.length === 0 ? (
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
No rig configured yet. Click "Add rig" to get started.
</div>
) : (
<div className="space-y-2">
{tree.map((station) => (
<StationRow
key={station.id}
station={station}
bands={bands}
expanded={expanded.has(station.id)}
editing={editing}
setEditing={setEditing}
onToggleExpanded={() => toggleExpanded(station.id)}
onUpdate={updateStation}
onDelete={() => removeStation(station.id)}
onAddAntenna={() => addAntenna(station.id)}
onUpdateAntenna={updateAntenna}
onDeleteAntenna={(antId) => removeAntenna(station.id, antId)}
/>
))}
</div>
)}
</div>
);
}
// ── Station row ────────────────────────────────────────────────────────
type StationRowProps = {
station: Station;
bands: string[];
expanded: boolean;
editing: string | null;
setEditing: (id: string | null) => void;
onToggleExpanded: () => void;
onUpdate: (s: Station) => void;
onDelete: () => void;
onAddAntenna: () => void;
onUpdateAntenna: (a: Antenna) => void;
onDeleteAntenna: (antId: number) => void;
};
function StationRow({
station, bands, expanded, editing, setEditing,
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
onUpdateAntenna, onDeleteAntenna,
}: StationRowProps) {
const editKey = `station:${station.id}`;
const isEditing = editing === editKey;
const [draft, setDraft] = useState({
name: station.name,
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
});
useEffect(() => {
if (!isEditing) setDraft({
name: station.name,
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
});
}, [isEditing, station.name, station.tx_pwr]);
function commit() {
const pwrNum = draft.tx_pwr.trim() === '' ? undefined : parseFloat(draft.tx_pwr);
onUpdate({
...station,
name: draft.name.trim() || station.name,
tx_pwr: Number.isFinite(pwrNum as number) ? (pwrNum as number) : undefined,
});
setEditing(null);
}
return (
<div className="rounded-md border border-border bg-card">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30">
<button
type="button"
onClick={onToggleExpanded}
className="p-0.5 hover:bg-accent/40 rounded"
title={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
<Radio className="size-4 text-muted-foreground" />
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<Input
autoFocus
className="h-7 text-sm flex-1"
placeholder="Rig name (also stamped as MY_RIG)"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
/>
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
<Input
type="number"
min={0}
step={1}
className="h-7 text-sm w-20 font-mono"
placeholder="100"
value={draft.tx_pwr}
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
/>
<Button size="sm" onClick={commit}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
</div>
) : (
<>
<span className="font-semibold text-sm">{station.name}</span>
{station.tx_pwr != null && (
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
)}
<div className="flex-1" />
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
<Edit2 className="size-3" />
</Button>
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
<Plus className="size-3" /> Antenna
</Button>
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
<Trash2 className="size-3" />
</Button>
</>
)}
</div>
{expanded && (
<div className="p-2 space-y-2">
{(station.antennas ?? []).length === 0 ? (
<div className="text-[11px] text-muted-foreground italic pl-6">
No antenna yet click "Antenna" above to add one.
</div>
) : (
(station.antennas ?? []).map((a) => (
<AntennaRow
key={a.id}
antenna={a}
bands={bands}
editing={editing}
setEditing={setEditing}
onUpdate={onUpdateAntenna}
onDelete={() => onDeleteAntenna(a.id)}
/>
))
)}
</div>
)}
</div>
);
}
// ── Antenna row ────────────────────────────────────────────────────────
type AntennaRowProps = {
antenna: Antenna;
bands: string[];
editing: string | null;
setEditing: (id: string | null) => void;
onUpdate: (a: Antenna) => void;
onDelete: () => void;
};
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
const editKey = `antenna:${antenna.id}`;
const isEditing = editing === editKey;
const [draft, setDraft] = useState({ name: antenna.name });
useEffect(() => {
if (!isEditing) setDraft({ name: antenna.name });
}, [isEditing, antenna.name]);
const enabledBands = new Map<string, Band>(
(antenna.bands ?? []).map((b) => [b.band, b])
);
function commitNames() {
onUpdate({
...antenna,
name: draft.name.trim() || antenna.name,
bands: antenna.bands ?? [],
});
setEditing(null);
}
function toggleBand(band: string, on: boolean) {
let next = [...(antenna.bands ?? [])];
if (on) {
if (!next.find((b) => b.band === band)) {
next.push({ band, is_default: false });
}
} else {
next = next.filter((b) => b.band !== band);
}
onUpdate({ ...antenna, bands: next });
}
function setDefault(band: string, isDefault: boolean) {
const next = (antenna.bands ?? []).map((b) =>
b.band === band ? { ...b, is_default: isDefault } : b
);
onUpdate({ ...antenna, bands: next });
}
return (
<div className="rounded border border-border/70 bg-muted/10">
<div className="flex items-center gap-2 px-2 py-1.5">
<AntennaIcon className="size-3.5 text-muted-foreground ml-3" />
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<Input
autoFocus
className="h-7 text-sm flex-1"
placeholder="Antenna name (also stamped as MY_ANTENNA)"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
/>
<Button size="sm" onClick={commitNames}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
</div>
) : (
<>
<span className="text-sm font-medium">{antenna.name}</span>
<div className="flex-1" />
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)}>
<Edit2 className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete}>
<Trash2 className="size-3" />
</Button>
</>
)}
</div>
<div className="px-3 pb-2 pl-8 flex flex-wrap gap-1.5">
{bands.length === 0 ? (
<span className="text-[10px] text-muted-foreground italic">No band configured in Settings Bands.</span>
) : bands.map((band) => {
const entry = enabledBands.get(band);
const enabled = !!entry;
const isDefault = !!entry?.is_default;
return (
<div
key={band}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
isDefault
? 'border-amber-400 bg-amber-50 shadow-sm'
: enabled
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-muted/30 text-muted-foreground'
)}
>
<Checkbox
checked={enabled}
onCheckedChange={(c) => toggleBand(band, !!c)}
className="size-3"
/>
<span className={isDefault ? 'font-semibold' : undefined}>{band}</span>
{enabled && (
<button
type="button"
onClick={() => setDefault(band, !isDefault)}
className={cn(
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
isDefault
? 'bg-amber-400 text-white'
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
)}
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
>
<Star className={cn('size-3', isDefault && 'fill-current')} />
<span className="text-[9px] font-bold uppercase tracking-wider">
{isDefault ? 'Default' : 'Set'}
</span>
</button>
)}
</div>
);
})}
</div>
</div>
);
}