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.
132 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|