Files
OpsLog/frontend/src/components/TunerCard.tsx
T
rouggy 00bfee4ed2 refactor(flex): single shared MeterBar so Flex/amp/tuner meters are identical
The FlexRadio panel, AmpCard and TunerCard each carried their own copy of the
MeterBar (LED-bar) component. Even small drift between the copies made the tuner
meters look slightly off in height/LED size vs the others. Extracted one shared
components/MeterBar.tsx (segments, bar height, padding) and imported it in all
three, so every meter renders at exactly the same size. No behaviour change.
2026-07-25 11:44:23 +02:00

132 lines
7.9 KiB
TypeScript

import { useState } from 'react';
import { Gauge, Radio, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
// mirrors the native app's two channels (A / B) with their source, frequency and
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
// drives the backend directly (no local state) — the caller's ~1.5s poll
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
// so they're the exact same size as the Flex/amp meters.
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
</button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div>
);
}
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
// antenna, matching the two rows of the native 4O3A app.
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
onSelect: () => void; t: (k: string, v?: any) => string;
}) {
const cls = ptt
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)]'
: active
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
: 'bg-card text-foreground/80 border-border hover:bg-muted';
const src = ch.mode_str || '—';
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
return (
<button type="button" onClick={onSelect}
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
<div className="flex items-center gap-1.5">
<span className="font-extrabold text-sm">{letter}</span>
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
)}
</div>
</button>
);
}
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
const connected = !!status.connected;
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
const active = status.active ?? 1;
const a: TGChannel = status.a ?? {};
const b: TGChannel = status.b ?? {};
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
return (
<Card icon={Gauge} title={title} accent="#f59e0b">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{status.operate ? 'OPERATE' : 'STANDBY'}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusAutotune().catch(() => {})}
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
{t('tgp.bypass')}
</button>
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
</span>
<div className="flex-1" />
{status.message && (
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold"> {status.message}</span>
)}
</div>
{connected && (
<>
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
<div className="flex items-stretch gap-2">
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
</div>
{/* PWR + SWR meters — same grid as the Flex/amp meters so they match size. */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
</div>
</>
)}
</Card>
);
}