rigs completed
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user