478 lines
25 KiB
TypeScript
478 lines
25 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { cn } from '@/lib/utils';
|
||
import { useI18n } from '@/lib/i18n';
|
||
import { writeUiPref } from '@/lib/uiPref';
|
||
import { RotorCompass } from '@/components/RotorCompass';
|
||
import {
|
||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||
} from '../../wailsjs/go/main/App';
|
||
|
||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||
|
||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
||
type Relay = { number: number; label: string; on: boolean };
|
||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||
|
||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
||
|
||
function blankDevice(): Device {
|
||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||
}
|
||
|
||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||
|
||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||
// passed in (so the panel can also order this widget); it drives the shared
|
||
// rotator backend.
|
||
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||
}) {
|
||
const [goto, setGoto] = useState('');
|
||
const [err, setErr] = useState('');
|
||
|
||
const turn = (az: number) => {
|
||
const a = ((Math.round(az) % 360) + 360) % 360;
|
||
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||
};
|
||
const poll = refetch;
|
||
|
||
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||
return (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<Compass className="size-4 text-primary" />
|
||
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||
</div>
|
||
<div className="p-3 flex gap-4 items-start">
|
||
<RotorCompass
|
||
bearing={bearing ?? null}
|
||
headings={hd.ok ? [hd.azimuth] : []}
|
||
centerLat={centerLat ?? null}
|
||
centerLon={centerLon ?? null}
|
||
rotorEnabled={hd.ok}
|
||
onGoto={(az) => turn(az)}
|
||
/>
|
||
<div className="flex-1 min-w-0 space-y-2">
|
||
<div className="font-mono">
|
||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
{presets.map(([lbl, az]) => (
|
||
<button key={lbl} type="button" onClick={() => turn(az)}
|
||
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||
{lbl}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||
placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
|
||
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||
</div>
|
||
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||
</Button>
|
||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
|
||
|
||
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||
|
||
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||
// 1 = driven element, then Director 1, 2, 3…
|
||
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||
if (i === 0) return t('station.reflector');
|
||
if (i === 1) return t('station.driven');
|
||
return `${t('station.director')} ${i - 1}`;
|
||
}
|
||
|
||
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||
const [err, setErr] = useState('');
|
||
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||
const [reading, setReading] = useState(false);
|
||
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||
const isUB = ant.type !== 'steppir';
|
||
|
||
const readLengths = useCallback(async () => {
|
||
setReading(true); setErr('');
|
||
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
finally { setReading(false); }
|
||
}, []);
|
||
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||
// +/- starts from the real values rather than blind.
|
||
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||
|
||
// Nudge one element by ±2 mm from its current known length.
|
||
const nudge = async (i: number, delta: number) => {
|
||
const cur = lengths[i] ?? 0;
|
||
const next = Math.max(0, cur + delta);
|
||
setBusyEl(i); setErr('');
|
||
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||
try { await MotorSetElement(i, next); refetch(); }
|
||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
finally { setBusyEl(null); }
|
||
};
|
||
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||
return (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<AntennaIcon className="size-4 text-primary" />
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||
</div>
|
||
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||
</div>
|
||
<div className="p-3 space-y-3">
|
||
<div>
|
||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||
<div className="flex gap-1">
|
||
{dirs.map(([d, lbl]) => (
|
||
<button key={d} type="button" disabled={!ant.connected}
|
||
onClick={() => run(SetUltrabeamDirection(d))}
|
||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||
{lbl}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button type="button" disabled={!ant.connected}
|
||
onClick={() => run(UltrabeamRetract())}
|
||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||
</button>
|
||
|
||
{isUB && (
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-1.5">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||
</button>
|
||
</div>
|
||
{lengths.length === 0 ? (
|
||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
||
3-element beam only uses the first few. */}
|
||
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||
<Minus className="size-3.5" />
|
||
</button>
|
||
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums">
|
||
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||
</span>
|
||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||
onClick={() => nudge(i, ELEMENT_STEP)}
|
||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||
<Plus className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||
</div>
|
||
)}
|
||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||
const { t } = useI18n();
|
||
const [devices, setDevices] = useState<Device[]>([]);
|
||
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
|
||
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||
const [order, setOrder] = useState<string[]>(() => {
|
||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||
});
|
||
const dragId = useRef<string | null>(null);
|
||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||
|
||
const loadDevices = useCallback(async () => {
|
||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||
}, []);
|
||
|
||
const poll = useCallback(async () => {
|
||
try {
|
||
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
|
||
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||
} catch { /* ignore transient */ }
|
||
}, []);
|
||
const pollRot = useCallback(async () => {
|
||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||
}, []);
|
||
const pollAnt = useCallback(async () => {
|
||
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||
}, []);
|
||
|
||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||
useEffect(() => {
|
||
poll(); pollRot(); pollAnt();
|
||
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||
return () => window.clearInterval(id);
|
||
}, [poll, pollRot, pollAnt, devices.length]);
|
||
|
||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||
// Reorder so `dragged` lands just before `target`.
|
||
const onDrop = (targetId: string) => {
|
||
const src = dragId.current; dragId.current = null;
|
||
if (!src || src === targetId) return;
|
||
const ids = widgetIds.filter((id) => id !== src);
|
||
const at = ids.indexOf(targetId);
|
||
ids.splice(at < 0 ? ids.length : at, 0, src);
|
||
persistOrder(ids);
|
||
};
|
||
|
||
const persist = async (next: Device[]) => {
|
||
setDevices(next);
|
||
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
|
||
await loadDevices();
|
||
poll();
|
||
};
|
||
|
||
const toggle = async (dev: Device, relay: number, on: boolean) => {
|
||
const key = `${dev.id}:${relay}`;
|
||
setBusy((b) => ({ ...b, [key]: true }));
|
||
// Optimistic flip so the switch feels instant; the poll reconciles.
|
||
setStatus((st) => {
|
||
const d = st[dev.id]; if (!d) return st;
|
||
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
|
||
});
|
||
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
|
||
await poll();
|
||
setBusy((b) => ({ ...b, [key]: false }));
|
||
};
|
||
|
||
const saveEdit = async () => {
|
||
if (!editing) return;
|
||
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
|
||
const exists = devices.some((x) => x.id && x.id === d.id);
|
||
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
|
||
setEditing(null);
|
||
};
|
||
|
||
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
|
||
|
||
// Build the widget list (rotator first by default, then devices), then order it
|
||
// by the saved drag order — unknown ids fall to the end in their natural order.
|
||
const deviceCard = (dev: Device) => {
|
||
const st = status[dev.id];
|
||
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||
return (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<PlugZap className="size-4 text-primary" />
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
||
</div>
|
||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||
</div>
|
||
<div className="p-3 grid grid-cols-2 gap-2">
|
||
{relays.map((r) => {
|
||
const key = `${dev.id}:${r.number}`;
|
||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||
return (
|
||
<button key={r.number} type="button" disabled={!st?.connected}
|
||
onClick={() => toggle(dev, r.number, !r.on)}
|
||
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
||
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block text-xs font-medium truncate">{label}</span>
|
||
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
||
{r.on ? t('station.on') : t('station.off')}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||
if (rot.enabled) {
|
||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||
}
|
||
if (ant.enabled) {
|
||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||
}
|
||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||
|
||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||
const widgetIds = ordered.map((w) => w.id);
|
||
|
||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
|
||
|
||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||
// fill however many columns are available.
|
||
const gridCols: Record<string, string> = {
|
||
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
|
||
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
|
||
};
|
||
|
||
return (
|
||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||
{(['auto', '2', '3', '4'] as const).map((c) => (
|
||
<button key={c} type="button"
|
||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||
{c === 'auto' ? t('station.colsAuto') : c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{noDevices && !editing && (
|
||
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||
{t('station.empty')}
|
||
</div>
|
||
)}
|
||
|
||
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
||
{ordered.map((w) => (
|
||
<div key={w.id} draggable
|
||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
||
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
||
{w.node}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{!noDevices && (
|
||
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
|
||
)}
|
||
|
||
{editing && (
|
||
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||
const setType = (type: string) => {
|
||
const n = RELAY_COUNT[type] ?? 5;
|
||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||
onChange({ ...device, type, labels });
|
||
};
|
||
const isKM = device.type === 'kmtronic';
|
||
return (
|
||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('station.type')}</Label>
|
||
<Select value={device.type} onValueChange={setType}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.name')}</Label>
|
||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||
<Label>{t('station.host')}</Label>
|
||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||
</div>
|
||
{isKM && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.user')}</Label>
|
||
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.pass')}</Label>
|
||
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.labels')}</Label>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{device.labels.map((lab, i) => (
|
||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|