Files
OpsLog/frontend/src/components/DvkPanel.tsx
T
rouggy 2823f3e401 feat: Station Control amp parity + all amps, Help→Send log to F4BPO, bulk-edit frequency, fix Cluster midnight sort
- Station Control: the amplifier panel is now the exact same card as the FlexRadio panel (extracted shared AmpCard: OPERATE/STANDBY, ON/OFF, power level, output-power bar, live FWD/ID/temp meters). Every configured amp gets its own card — no dropdown.
- Help → "Send log to F4BPO" (only when SMTP is configured): e-mails opslog.log + previous session + crash log to the developer. New email.SendFiles for multi-attachment.
- Bulk edit: new "Frequency (MHz)" field sets freq_hz AND recomputes band; out-of-band values rejected. Fixes a run logged on a stale/default freq after CAT dropped.
- Cluster: Time column sorted lexically on the HHMMZ string, so spots after 0000Z sorted below 2359Z and looked frozen at the UTC rollover. Now sorts by real arrival time.
- Also folds in the DVK auto-CQ/2-column layout and Station Control dashboard batch (changelog 0.20.10, EN+FR).
2026-07-23 13:22:02 +02:00

103 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Mic, Square, X, Radio, Repeat } 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;
autoCq: boolean;
autoCqSecs: number;
onToggleAutoCq: (on: boolean) => void;
onSetAutoCqSecs: (n: number) => void;
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
};
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6
// 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, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
const { t } = useI18n();
const recorded = messages.filter((m) => m.has_audio);
const anyAudio = recorded.length > 0;
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>
) : (
// Only the recorded slots are shown. Two columns filling top-to-bottom
// (rows sized to the count) — use the width instead of scrolling, so the
// panel keeps the same height as the other reserved-area widgets.
<div className="grid grid-flow-col gap-1"
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
{recorded.map((m) => (
<button
key={m.slot}
type="button"
disabled={!phoneOk}
onClick={() => onPlay(m.slot)}
title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
phoneOk
? '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 || t('dvkp.message')}</span>
{(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
</button>
))}
</div>
)}
</div>
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
non-phone mode (the DVK won't transmit there). */}
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
onChange={(e) => onToggleAutoCq(e.target.checked)} />
<Repeat className="size-3" /> {t('dvkp.autoCq')}
</label>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
<input type="number" min={0} max={120} value={autoCqSecs}
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
<span className="text-[10px] text-muted-foreground">s</span>
</div>
{!phoneOk && (
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
)}
</div>
);
}