theme: self-heal the persisted theme after mount. The synchronous boot read only looks at localStorage, which is empty when the WebView cleared its storage or when syncPortablePrefs ran before the backend wired its settings store (GetUIPref returned "" with no error) — so a restart could silently land on the default light theme. ThemeProvider now re-reads the portable pref from the DB once the backend is up (retrying briefly to ride out a slow startup) and applies it, without clobbering a manual pick. ClusterGrid: the Call cell now uses a danger pill for NEW DXCC, consistent with the NEW BAND / NEW MODE pills, instead of a full-cell red fill. cellChip inherits the column's font size (no fixed 9px / height) so a pill around a callsign stays the same size as the plain callsigns beside it. DvkPanel: the voice-keyer TX indicator (LED + label) is red while transmitting, not orange. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
72 lines
3.5 KiB
TypeScript
72 lines
3.5 KiB
TypeScript
import { Mic, Square, X, Radio } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { cn } from '@/lib/utils';
|
||
import { useI18n } from '@/lib/i18n';
|
||
|
||
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||
|
||
type Props = {
|
||
messages: DVKMsg[];
|
||
status: DVKStat;
|
||
onPlay: (slot: number) => void;
|
||
onStop: () => void;
|
||
onClose: () => void;
|
||
};
|
||
|
||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||
const { t } = useI18n();
|
||
const anyAudio = messages.some((m) => m.has_audio);
|
||
return (
|
||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||
<Mic className="size-3.5 text-primary" />
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||
{status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
|
||
<div className="flex-1" />
|
||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||
<Square className="size-3" /> {t('dvkp.stop')}
|
||
</Button>
|
||
<button className="text-muted-foreground hover:text-foreground" title={t('dvkp.disable')} onClick={onClose}>
|
||
<X className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 min-h-0 overflow-auto p-2">
|
||
{!anyAudio ? (
|
||
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
|
||
<Radio className="size-5 opacity-50" />
|
||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-1">
|
||
{messages.map((m) => (
|
||
<button
|
||
key={m.slot}
|
||
type="button"
|
||
disabled={!m.has_audio}
|
||
onClick={() => onPlay(m.slot)}
|
||
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
||
className={cn(
|
||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||
m.has_audio
|
||
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||
)}
|
||
>
|
||
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span>
|
||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|