feat: Complete translation in French
This commit is contained in:
@@ -2330,8 +2330,8 @@ export default function App() {
|
||||
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
||||
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
||||
]},
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||
{ name: 'help', label: t('menu.help'), items: [
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, t]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ADIFFields } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldDef = {
|
||||
name: string; kind: string; category: string;
|
||||
@@ -21,6 +22,7 @@ interface Props {
|
||||
// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is
|
||||
// what makes "100% of ADIF fields available" true without 160 DB columns.
|
||||
export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [dict, setDict] = useState<FieldDef[]>([]);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
|
||||
@@ -68,9 +70,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it,
|
||||
or type a custom/vendor tag (e.g. <code className="bg-muted px-1 rounded font-mono">APP_*</code>).
|
||||
Stored losslessly and exported in the <strong>full</strong> ADIF mode.
|
||||
{t('adx.introPart1')}<code className="bg-muted px-1 rounded font-mono">APP_*</code>{t('adx.introPart2')}<strong>{t('adx.fullMode')}</strong>{t('adx.introPart3')}
|
||||
</p>
|
||||
|
||||
{/* Add a field */}
|
||||
@@ -79,21 +79,21 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
<Combobox
|
||||
value=""
|
||||
options={addable}
|
||||
placeholder="Add ADIF field…"
|
||||
placeholder={t('adx.addFieldPh')}
|
||||
allowFreeText
|
||||
onChange={addField}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
|
||||
Show deprecated
|
||||
{t('adx.showDeprecated')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Current entries */}
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
|
||||
No extra ADIF fields. Use the picker above to add one.
|
||||
{t('adx.noExtra')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -105,11 +105,11 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
<span className="font-mono text-xs font-semibold">{k}</span>
|
||||
{def && (
|
||||
<span className="block text-[10px] text-muted-foreground leading-tight">
|
||||
{def.category}{def.deprecated ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''}
|
||||
{def.category}{def.deprecated ? ' · ' + t('adx.deprecated') : ''}{def.intl ? ' · ' + t('adx.intl') : ''}
|
||||
{!def && ''}
|
||||
</span>
|
||||
)}
|
||||
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</span>}
|
||||
{!def && <span className="block text-[10px] text-amber-600 leading-tight">{t('adx.nonStandard')}</span>}
|
||||
</div>
|
||||
<Input
|
||||
className="flex-1 h-8 text-xs font-mono"
|
||||
@@ -118,7 +118,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
/>
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(k)} title="Remove field"
|
||||
onClick={() => remove(k)} title={t('adx.removeField')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
|
||||
} from '@/../wailsjs/go/main/App';
|
||||
@@ -32,6 +33,7 @@ function emptyRule(): Rule {
|
||||
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
|
||||
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState('');
|
||||
const shown = useMemo(
|
||||
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
|
||||
@@ -43,7 +45,7 @@ function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }
|
||||
{searchable && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
|
||||
<Search className="size-3 text-muted-foreground" />
|
||||
<input className="flex-1 bg-transparent text-xs outline-none" placeholder="Filter…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<input className="flex-1 bg-transparent text-xs outline-none" placeholder={t('altm.filterPh')} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('overflow-y-auto p-1', height)}>
|
||||
@@ -53,10 +55,10 @@ function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }
|
||||
<span className="truncate">{o}</span>
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">no match</div>}
|
||||
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">{t('altm.noMatch')}</div>}
|
||||
</div>
|
||||
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
|
||||
{(selected?.length ?? 0) === 0 ? 'none selected = ALL' : `${selected!.length} selected`}
|
||||
{(selected?.length ?? 0) === 0 ? t('altm.noneAll') : t('altm.nSelected', { n: selected!.length })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -65,6 +67,7 @@ function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }
|
||||
export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [draft, setDraft] = useState<Rule | null>(null);
|
||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||
@@ -88,14 +91,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) { setErr('Give the rule a name'); return; }
|
||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function del() {
|
||||
if (!draft) return;
|
||||
if (!draft.id) { setDraft(null); return; }
|
||||
if (!window.confirm(`Delete alert "${draft.name}"?`)) return;
|
||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
@@ -104,19 +107,19 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> Alert management</DialogTitle>
|
||||
<DialogDescription>Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).</DialogDescription>
|
||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
|
||||
{/* Rule list */}
|
||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">Rules</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">No rules yet — click +</div>}
|
||||
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||
{rules.map((r) => (
|
||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||
@@ -129,7 +132,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
))}
|
||||
</div>
|
||||
<div className="px-2 py-1.5 border-t border-border/60">
|
||||
<Label className="text-[10px] text-muted-foreground">Alert e-mail to</Label>
|
||||
<Label className="text-[10px] text-muted-foreground">{t('altm.emailTo')}</Label>
|
||||
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
|
||||
onChange={(e) => setEmailTo(e.target.value)}
|
||||
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
|
||||
@@ -139,60 +142,60 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
{/* Editor */}
|
||||
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
|
||||
{!draft ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select or create a rule.</div>
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">{t('altm.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-2 pt-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="def">Definition</TabsTrigger>
|
||||
<TabsTrigger value="call">Call / DXCC</TabsTrigger>
|
||||
<TabsTrigger value="bm">Band / Mode</TabsTrigger>
|
||||
<TabsTrigger value="orig">Origin</TabsTrigger>
|
||||
<TabsTrigger value="def">{t('altm.tabDef')}</TabsTrigger>
|
||||
<TabsTrigger value="call">{t('altm.tabCall')}</TabsTrigger>
|
||||
<TabsTrigger value="bm">{t('altm.tabBandMode')}</TabsTrigger>
|
||||
<TabsTrigger value="orig">{t('altm.tabOrigin')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
<TabsContent value="def" className="mt-0 space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Rule name</Label>
|
||||
<Label className="text-xs">{t('altm.ruleName')}</Label>
|
||||
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> Alert enabled
|
||||
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> {t('altm.alertEnabled')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-40 shrink-0">Alert again after (min)</Label>
|
||||
<Label className="text-xs w-40 shrink-0">{t('altm.againAfter')}</Label>
|
||||
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
|
||||
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
|
||||
<span className="text-[11px] text-muted-foreground">0 = once/session · -1 = always</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('altm.againHint')}</span>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<Label className="text-xs font-semibold">Actions</Label>
|
||||
<Label className="text-xs font-semibold">{t('altm.actions')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> Visual</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> Sound</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> E-mail</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> {t('altm.visual')}</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> {t('altm.sound')}</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> {t('altm.email')}</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> Skip calls already worked (same band + mode)
|
||||
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> {t('altm.skipWorked')}
|
||||
</label>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Callsigns (one per line, wildcards: IW3*, */P)</Label>
|
||||
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
||||
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||
value={(draft.calls ?? []).join('\n')}
|
||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Countries (DXCC)</Label>
|
||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label className="text-xs">Continents</Label>
|
||||
<Label className="text-xs">{t('altm.continents')}</Label>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{CONTINENTS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
@@ -205,21 +208,21 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
|
||||
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Bands</Label>
|
||||
<Label className="text-xs">{t('altm.bands')}</Label>
|
||||
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Modes</Label>
|
||||
<Label className="text-xs">{t('altm.modes')}</Label>
|
||||
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Spotter callsign (wildcard)</Label>
|
||||
<Input className="font-mono" placeholder="e.g. F* or DL1ABC" value={draft.spotter_call ?? ''}
|
||||
<Label className="text-xs">{t('altm.spotterCall')}</Label>
|
||||
<Input className="font-mono" placeholder={t('altm.spotterCallPh')} value={draft.spotter_call ?? ''}
|
||||
onChange={(e) => patch({ spotter_call: e.target.value })} />
|
||||
<Label className="text-xs mt-2 block">Spotter continents</Label>
|
||||
<Label className="text-xs mt-2 block">{t('altm.spotterContinents')}</Label>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{CONTINENTS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
@@ -229,7 +232,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Spotter countries</Label>
|
||||
<Label className="text-xs">{t('altm.spotterCountries')}</Label>
|
||||
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -239,8 +242,8 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> Delete</Button>
|
||||
<Button size="sm" onClick={save}>Save rule</Button>
|
||||
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
@@ -248,7 +251,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-5 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> Close</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> {t('altm.close')}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Antenna, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type AGAntenna = { index: number; name: string };
|
||||
export type AGStatus = {
|
||||
@@ -26,6 +27,7 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const list = status.antennas ?? [];
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
@@ -41,7 +43,7 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? `Port ${letter} — click to deselect` : `Select on port ${letter}`}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
@@ -57,9 +59,9 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||
<span className="flex-1" />
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
|
||||
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? 'online' : 'offline'}</span>
|
||||
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="Close">
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -67,11 +69,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||
{!status.connected ? (
|
||||
<div className="text-center py-6 text-xs space-y-2">
|
||||
<div className="text-muted-foreground italic animate-pulse">Connecting…</div>
|
||||
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
|
||||
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="text-muted-foreground italic text-center py-6 text-xs">No antennas configured.</div>
|
||||
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
|
||||
) : list.map((a) => {
|
||||
const aActive = status.port_a === a.index;
|
||||
const bActive = status.port_b === a.index;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
@@ -102,6 +103,7 @@ function Field2({ label, children }: { label: string; children: React.ReactNode
|
||||
// DxccFilter — pick the entities an award is scoped to by country name (like the
|
||||
// QSO editor), resolving each to its ADIF DXCC number. Stored as number[].
|
||||
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
|
||||
const { t } = useI18n();
|
||||
const [names, setNames] = useState<Record<number, string>>({});
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -124,7 +126,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
|
||||
<Combobox value="" options={countries} placeholder={t('awed.addCountry')} onChange={addCountry} className="h-8 w-full" />
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((n) => (
|
||||
@@ -141,6 +143,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
||||
}
|
||||
|
||||
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [fields, setFields] = useState<string[]>([]);
|
||||
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
||||
@@ -208,7 +211,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
setErr('');
|
||||
try {
|
||||
const p = await ExportAwards();
|
||||
if (p) setErr(`Awards exported to:\n${p}`);
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import an award bundle: definitions are upserted by code, reference lists
|
||||
@@ -221,7 +224,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||
setDefs((d ?? []) as any); setSel(0);
|
||||
onSaved();
|
||||
setErr(`Imported ${r.awards} award(s) and ${r.references} reference(s).`);
|
||||
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateList(code: string) {
|
||||
@@ -244,7 +247,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>Award management</DialogTitle>
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
@@ -253,7 +256,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input className="h-7 pl-7 text-xs" placeholder="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<Input className="h-7 pl-7 text-xs" placeholder={t('awed.searchAwards')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
@@ -268,22 +271,22 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> New award
|
||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{err && <div onClick={() => setErr('')} title="Click to dismiss" className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">Award info</TabsTrigger>
|
||||
<TabsTrigger value="type">Award type</TabsTrigger>
|
||||
<TabsTrigger value="conf">Confirmation</TabsTrigger>
|
||||
<TabsTrigger value="refs">References</TabsTrigger>
|
||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
@@ -291,29 +294,29 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<TabsContent value="info" className="mt-0 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder="Award name" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> Valid</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label="Award URL"><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label="Reference URL"><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Valid from"><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label="Valid to"><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Label className="text-xs text-muted-foreground pt-1.5">DXCC filter</Label>
|
||||
<Label className="text-xs text-muted-foreground pt-1.5">{t('awed.dxccFilter')}</Label>
|
||||
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid bands (empty = all)</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Emission (empty = all)</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid modes (empty = all)</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validBands')}</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.emission')}</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validModes')}</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Award type ── */}
|
||||
<TabsContent value="type" className="mt-0 space-y-2.5">
|
||||
<Field2 label="Award type">
|
||||
<Field2 label={t('awed.awardType')}>
|
||||
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{
|
||||
@@ -324,18 +327,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> Dynamic references (not predefined — any value counts, like POTA)</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
||||
|
||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||
<p className="text-[11px] text-muted-foreground">QSO parameters (used by QSOFIELDS / REFERENCE types)</p>
|
||||
<Field2 label="Search in field">
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
||||
<Field2 label={t('awed.searchInField')}>
|
||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<Field2 label="Match by">
|
||||
<Field2 label={t('awed.matchBy')}>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
||||
@@ -344,21 +347,21 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
))}
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> Exact match (else search reference inside the field)</label>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
</div>
|
||||
|
||||
{/* Additional OR searches: a QSO earns a reference if the
|
||||
primary rule OR any of these match. */}
|
||||
<div className="border-t pt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||
<Button size="sm" variant="outline" className="h-7"
|
||||
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
||||
<Plus className="size-3.5" /> Add OR
|
||||
<Plus className="size-3.5" /> {t('awed.addOr')}
|
||||
</Button>
|
||||
</div>
|
||||
{(cur.or_rules ?? []).map((r, ri) => {
|
||||
@@ -367,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
return (
|
||||
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">OR — search in</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('awed.orSearchIn')}</span>
|
||||
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
@@ -379,12 +382,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> {t('awed.exact')}</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title={t('awed.removeOrSearch')}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder={t('awed.orPatternPlaceholder')} />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -397,20 +400,20 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<TabsContent value="conf" className="mt-0 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Confirmation (worked → confirmed)</Label>
|
||||
<Label className="text-xs font-semibold">{t('awed.confirmationLabel')}</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleIn('confirm', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Validation (confirmed → validated)</Label>
|
||||
<Label className="text-xs font-semibold">{t('awed.validationLabel')}</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.validate ?? []).includes(c.id)} onCheckedChange={() => toggleIn('validate', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> Export award in ADIF credit_granted field</label>
|
||||
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── References ── */}
|
||||
@@ -428,16 +431,16 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title="Export all award definitions + reference lists to a JSON backup">
|
||||
<Download className="size-3.5 mr-1" /> Export…
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={importAwards} title="Import an award bundle (definitions + reference lists)">
|
||||
<Upload className="size-3.5 mr-1" /> Import…
|
||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -451,6 +454,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
code: string; presets: Preset[]; meta?: RefMeta;
|
||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [selCode, setSelCode] = useState<string | null>(null);
|
||||
@@ -486,7 +490,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
}, [code, q, large]);
|
||||
|
||||
async function populateBuiltin() {
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(t('awed.populatedMsg', { n })); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -505,7 +509,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function addRef() {
|
||||
const c = prompt('New reference code:')?.trim().toUpperCase();
|
||||
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||
if (!c) return;
|
||||
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
||||
await saveRef(r); setSelCode(c);
|
||||
@@ -520,7 +524,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function importBulk() {
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(`Imported ${n} references.`); }
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(t('awed.importedRefsMsg', { n })); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -528,46 +532,46 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
|
||||
<span className="text-xs text-muted-foreground">{t('awed.referenceCount')} <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
|
||||
<div className="flex-1" />
|
||||
<Select value="" onValueChange={applyPreset}>
|
||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder={t('awed.applyPreset')} /></SelectTrigger>
|
||||
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>Paste / CSV</Button>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>{t('awed.pasteCsv')}</Button>
|
||||
{hasBuiltin && (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title="Replace with the shipped built-in list (DXCC entities, French departments, …)">
|
||||
Populate built-in
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title={t('awed.populateBuiltinTitle')}>
|
||||
{t('awed.populateBuiltin')}
|
||||
</Button>
|
||||
)}
|
||||
{meta?.can_update && (
|
||||
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
|
||||
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} Update online
|
||||
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} {t('awed.updateOnline')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> Add</Button>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> {t('awed.add')}</Button>
|
||||
</div>
|
||||
|
||||
{showBulk && (
|
||||
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
|
||||
<p className="text-[11px] text-muted-foreground">One reference per line: <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> (comma/semicolon/tab). Replaces the whole list.</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.onePerLine')} <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> {t('awed.replacesList')}</p>
|
||||
<Textarea rows={6} className="font-mono text-xs" value={bulk} onChange={(e) => setBulk(e.target.value)} placeholder={'ON,Ontario,,,1\nQC,Quebec,,,1'} />
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>Cancel</Button><Button size="sm" className="h-7" onClick={importBulk}>Import</Button></div>
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>{t('awed.cancel')}</Button><Button size="sm" className="h-7" onClick={importBulk}>{t('awed.import2')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[200px_1fr] gap-3">
|
||||
{/* List */}
|
||||
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder={t('awed.search')} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Searching…</div>}
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
|
||||
{!busy && large && q.trim().length < 2 && (
|
||||
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
|
||||
{t('awed.tooManyItems', { total: total.toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
|
||||
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('awed.noReferences')}</div>}
|
||||
{filtered.map((r) => (
|
||||
<button key={r.code} onClick={() => setSelCode(r.code)}
|
||||
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
|
||||
@@ -581,37 +585,37 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
{/* Per-reference editor */}
|
||||
<div className="border rounded p-3">
|
||||
{!sel ? (
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">Select a reference, or Add / import a list.</div>
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">{t('awed.selectReference')}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> Valid</label>
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
<div className="flex-1" />
|
||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Score</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
|
||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Bonus</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
|
||||
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Grid</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
|
||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
|
||||
@@ -17,6 +18,7 @@ interface Props {
|
||||
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
||||
// result assigns it to the QSO.
|
||||
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<Ref[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -50,12 +52,12 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
||||
{value ? (
|
||||
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
|
||||
<span className="font-mono font-semibold">{value}</span>
|
||||
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
|
||||
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={`Search ${label}…`}
|
||||
placeholder={t('awrp.searchLabel', { label })}
|
||||
value={q}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
@@ -63,9 +65,9 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
||||
)}
|
||||
{open && !value && (
|
||||
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
|
||||
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Searching…</div>}
|
||||
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('awrp.searching')}</div>}
|
||||
{!busy && results.length === 0 && (
|
||||
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
|
||||
<div className="px-3 py-2 text-muted-foreground">{countryOnly && dxcc ? t('awrp.noMatchDxcc') : t('awrp.noMatch')}</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../.
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||
const [awardCode, setAwardCode] = useState('POTA');
|
||||
@@ -208,9 +210,9 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
{/* Group / Sub from selected ref */}
|
||||
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||
<span className="text-muted-foreground text-[11px]">Group</span>
|
||||
<span className="text-muted-foreground text-[11px]">{t('awrs.group')}</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
|
||||
<span className="text-muted-foreground text-[11px]">Sub</span>
|
||||
<span className="text-muted-foreground text-[11px]">{t('awrs.sub')}</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||
</div>
|
||||
|
||||
@@ -225,7 +227,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
||||
← pick a reference
|
||||
{t('awrs.pickReference')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -236,10 +238,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
onClick={() => selectedRef && addRef(selectedRef)}
|
||||
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="size-3" />Add
|
||||
<Plus className="size-3" />{t('awrs.add')}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
|
||||
{dxcc ? `DXCC #${dxcc}` : t('awrs.enterCallsignFirst')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +250,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
{/* Added refs list */}
|
||||
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-0.5">No references added yet</p>
|
||||
<p className="text-[11px] text-muted-foreground italic py-0.5">{t('awrs.noRefsAdded')}</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
@@ -273,7 +275,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
{/* Right panel: reference search */}
|
||||
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
|
||||
<span className="text-xs font-semibold">References</span>
|
||||
<span className="text-xs font-semibold">{t('awrs.references')}</span>
|
||||
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
||||
{autoMatch && (
|
||||
<button
|
||||
@@ -285,17 +287,17 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
? 'border-emerald-300 bg-emerald-50 text-emerald-800 cursor-default'
|
||||
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100'
|
||||
}`}
|
||||
title={`The ${selField.toUpperCase()} field is ${autoMatch.code} — this award counts it automatically`}
|
||||
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
||||
>
|
||||
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||
<span className="text-emerald-700"> from {selField}</span>
|
||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">auto — click to add</span>}
|
||||
<span className="text-emerald-700"> {t('awrs.fromField', { field: selField })}</span>
|
||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">{t('awrs.autoClickToAdd')}</span>}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="Search…"
|
||||
placeholder={t('awrs.search')}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
@@ -306,22 +308,22 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
type="button"
|
||||
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
|
||||
className="text-left rounded border border-dashed border-primary/50 px-1.5 py-1 text-[11px] text-primary hover:bg-primary/10"
|
||||
title="Add this reference even though it isn't in the list yet (new / unlisted)"
|
||||
title={t('awrs.addUnlistedTitle')}
|
||||
>
|
||||
+ Add <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||
<span className="text-[10px] text-muted-foreground"> (unlisted)</span>
|
||||
{t('awrs.addPrefix')} <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||
<span className="text-[10px] text-muted-foreground"> {t('awrs.unlisted')}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
||||
{busy && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />Searching…
|
||||
<Loader2 className="size-3 animate-spin" />{t('awrs.searching')}
|
||||
</div>
|
||||
)}
|
||||
{/* Too many auto-results → require typed search */}
|
||||
{!busy && q.length < 2 && tooManyAuto && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
Type 2+ chars to search
|
||||
{t('awrs.typeToSearch')}
|
||||
</div>
|
||||
)}
|
||||
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||
@@ -329,16 +331,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
{isDynamic && !dxcc
|
||||
? 'Enter a callsign, or type to search.'
|
||||
: 'No references for this entity.'}
|
||||
? t('awrs.enterCallsignOrSearch')
|
||||
: t('awrs.noRefsForEntity')}
|
||||
</div>
|
||||
)}
|
||||
{/* Typed search, no results */}
|
||||
{!busy && q.length >= 2 && results.length === 0 && (
|
||||
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
||||
No results.
|
||||
{t('awrs.noResults')}
|
||||
<br />
|
||||
<span className="text-[10px]">Download reference lists in the Awards panel → Import data.</span>
|
||||
<span className="text-[10px]">{t('awrs.downloadLists')}</span>
|
||||
</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
@@ -61,6 +62,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||
|
||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||
// Computed results are cached per award code — each award is scanned only the
|
||||
// first time it's selected (or when explicitly rescanned).
|
||||
@@ -207,15 +209,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<AwardIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Awards</span>
|
||||
<span className="text-sm font-semibold">{t('awp.awards')}</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
||||
title={t('awp.rescanTitle')}>
|
||||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
Rescan
|
||||
{t('awp.rescan')}
|
||||
</Button>
|
||||
</div>
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
||||
@@ -223,7 +225,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{awardList.length > 0 && (
|
||||
<div className="px-2 py-2 border-b border-border/40">
|
||||
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an award…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
{awardList.map((a) => (
|
||||
<SelectItem key={a.code} value={a.code} className="text-xs">
|
||||
@@ -255,7 +257,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
<span className="text-emerald-600">{r.confirmed}</span>
|
||||
/<span className="text-foreground">{r.worked}</span>
|
||||
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
|
||||
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
|
||||
@@ -273,7 +275,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{!current ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{loading ? 'Computing…' : 'No data'}
|
||||
{loading ? t('awp.computing') : t('awp.noData')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -283,11 +285,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<span className="text-sm text-muted-foreground">{current.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
|
||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
|
||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
|
||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||
{current.total > 0 && (
|
||||
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
|
||||
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
|
||||
@@ -296,7 +298,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{/* Band breakdown */}
|
||||
{(current.bands ?? []).length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border/60">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(current.bands ?? []).map((b) => (
|
||||
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||
@@ -313,23 +315,23 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setRefFilter(k)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
||||
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
||||
title={t('awp.missingRefsTitle')}
|
||||
>
|
||||
<AlertTriangle className="size-3" /> Missing refs
|
||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{/* Legend */}
|
||||
@@ -339,9 +341,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -349,15 +351,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{view === 'stats' ? (
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
{statsLoading || !stats ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
|
||||
) : (
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
||||
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -384,8 +386,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||
{gridBands.map((b) => (
|
||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||
))}
|
||||
@@ -405,7 +407,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||
<button
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||
title={`${r.ref} · ${b} — click to view QSOs`}
|
||||
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
)}
|
||||
@@ -422,11 +424,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 pr-2 font-medium w-24">Ref</th>
|
||||
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||||
<th className="py-1.5 font-medium">Bands</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -436,10 +438,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
||||
: r.confirmed ? <span className="text-amber-600">confirmed</span>
|
||||
: <span className="text-stone-500">worked</span>}
|
||||
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
||||
: r.validated ? <span className="text-emerald-600">{t('awp.validated')}</span>
|
||||
: r.confirmed ? <span className="text-amber-600">{t('awp.confirmed')}</span>
|
||||
: <span className="text-stone-500">{t('awp.worked')}</span>}
|
||||
</td>
|
||||
<td className="py-1 font-mono text-muted-foreground">
|
||||
{r.bands.map((b) => (
|
||||
@@ -472,6 +474,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||
|
||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||
@@ -534,7 +537,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
const done = new Set(assignedIds);
|
||||
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||
setSel(new Set());
|
||||
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||||
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
|
||||
} catch (e: any) {
|
||||
setMsg(String(e?.message ?? e));
|
||||
} finally { setBusy(false); }
|
||||
@@ -556,26 +559,25 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||
<AlertTriangle className="size-4 text-amber-600" />
|
||||
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
||||
<span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
|
||||
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
|
||||
title="Recompute now — contacts you've fixed drop off the list">
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
|
||||
title={t('awp.recomputeTitle')}>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
||||
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
||||
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||||
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
|
||||
</div>
|
||||
|
||||
{/* Bulk-assign toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||
<span className="text-xs text-muted-foreground">{sel.size} selected →</span>
|
||||
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{refs.map((r) => (
|
||||
<SelectItem key={r.code} value={r.code}>
|
||||
@@ -585,29 +587,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
||||
</Button>
|
||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning…</div>
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
|
||||
) : qsos.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)
|
||||
{t('awp.noGaps')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||
<tr>
|
||||
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
|
||||
<SortTh k="callsign" label="Callsign" />
|
||||
<SortTh k="band" label="Band" />
|
||||
<SortTh k="mode" label="Mode" />
|
||||
<SortTh k="country" label="Country" />
|
||||
<SortTh k="qth" label="QTH / Note" />
|
||||
<SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
|
||||
<SortTh k="callsign" label={t('awp.callsign')} />
|
||||
<SortTh k="band" label={t('awp.band')} />
|
||||
<SortTh k="mode" label={t('awp.mode')} />
|
||||
<SortTh k="country" label={t('awp.country')} />
|
||||
<SortTh k="qth" label={t('awp.qthNote')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -633,8 +635,8 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
|
||||
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
|
||||
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
|
||||
{qsos.length} {t('awp.contactsWithoutRef')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -643,6 +645,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
|
||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
@@ -666,13 +669,13 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading…</div>
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
|
||||
) : qsos.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
|
||||
<div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
||||
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||
<tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qsos.map((q, i) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
|
||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||
@@ -79,13 +80,13 @@ function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
|
||||
// Human-readable label for a spot status — used in the pill hover tooltip
|
||||
// so the operator can see WHY a spot is coloured the way it is.
|
||||
function statusLabel(s: string): string {
|
||||
function statusLabel(s: string, t: (k: string) => string): string {
|
||||
switch (s) {
|
||||
case 'new': return 'NEW DXCC (entity never worked)';
|
||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
||||
case 'worked': return 'Worked (this band + mode already in log)';
|
||||
default: return 'Entity not resolved';
|
||||
case 'new': return t('bmp.statusNew');
|
||||
case 'new-band': return t('bmp.statusNewBand');
|
||||
case 'new-slot': return t('bmp.statusNewSlot');
|
||||
case 'worked': return t('bmp.statusWorked');
|
||||
default: return t('bmp.statusUnresolved');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +148,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
|
||||
const { t } = useI18n();
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
const [zoomIdx, setZoomIdx] = useState(0);
|
||||
@@ -330,8 +332,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
if (!range) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||
<div className="text-sm font-semibold mb-1">Band map</div>
|
||||
Not configured for {band || '—'}.
|
||||
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
||||
{t('bmp.notConfigured', { band: band || '—' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -364,34 +366,34 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex items-center gap-1 shrink-0">
|
||||
<span className="flex-1">Map · {band}</span>
|
||||
<span className="flex-1">{t('bmp.map')} · {band}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom out">
|
||||
title={t('bmp.zoomOut')}>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom in">
|
||||
title={t('bmp.zoomIn')}>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
<button type="button" onClick={recenterOnRig}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Scroll to current rig frequency">
|
||||
title={t('bmp.scrollToRig')}>
|
||||
<Crosshair className="size-3" />
|
||||
</button>
|
||||
{onToggleSide && (
|
||||
<button type="button" onClick={onToggleSide}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title={side === 'right' ? 'Move band map to the left' : 'Move band map to the right'}>
|
||||
title={side === 'right' ? t('bmp.moveLeft') : t('bmp.moveRight')}>
|
||||
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Hide band map">
|
||||
title={t('bmp.hide')}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -496,7 +498,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
style.pill,
|
||||
)}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
>
|
||||
{/* Status accent strip on the left */}
|
||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||
@@ -515,14 +517,14 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
</div>
|
||||
{/* Colour legend — what each pill colour means. */}
|
||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||
<LegendDot cls="bg-rose-400" label="New DXCC" />
|
||||
<LegendDot cls="bg-amber-400" label="New band" />
|
||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
||||
<LegendDot cls="bg-rose-400" label={t('bmp.legendNewDxcc')} />
|
||||
<LegendDot cls="bg-amber-400" label={t('bmp.legendNewBand')} />
|
||||
<LegendDot cls="bg-yellow-300" label={t('bmp.legendNewSlot')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden — top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
|
||||
{t('bmp.footerHint')}
|
||||
{hidden > 0 && <span className="text-amber-600"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldKind = 'status' | 'text';
|
||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||
@@ -17,52 +18,60 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
||||
// the backend BulkUpdateField whitelist.
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const FIELDS: FieldDef[] = [
|
||||
// QSL / upload status
|
||||
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||
// My station / operator
|
||||
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
|
||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sota_ref', label: 'bulk.fMySota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_pota_ref', label: 'bulk.fMyPota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||
// Misc
|
||||
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
|
||||
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
|
||||
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
|
||||
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
|
||||
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||
{ id: 'ant', label: 'bulk.fAnt', group: 'Misc', kind: 'text' },
|
||||
];
|
||||
|
||||
// label holds an i18n key.
|
||||
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||
{ v: 'Y', label: 'Y — Yes / uploaded' },
|
||||
{ v: 'N', label: 'N — No' },
|
||||
{ v: 'R', label: 'R — Requested' },
|
||||
{ v: 'I', label: 'I — Ignore' },
|
||||
{ v: '_', label: '(blank — clear)' },
|
||||
{ v: 'Y', label: 'bulk.statusY' },
|
||||
{ v: 'N', label: 'bulk.statusN' },
|
||||
{ v: 'R', label: 'bulk.statusR' },
|
||||
{ v: 'I', label: 'bulk.statusI' },
|
||||
{ v: '_', label: 'bulk.statusBlank' },
|
||||
];
|
||||
|
||||
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
||||
// Maps the internal group key → its i18n label key.
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
'QSL / upload': 'bulk.groupQsl',
|
||||
'My station': 'bulk.groupMyStation',
|
||||
'Misc': 'bulk.groupMisc',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -75,6 +84,7 @@ type Props = {
|
||||
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
||||
// so they become eligible for upload.
|
||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [field, setField] = useState('hrdlog_upload');
|
||||
const [statusValue, setStatusValue] = useState('R');
|
||||
const [textValue, setTextValue] = useState('');
|
||||
@@ -105,60 +115,59 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk edit field</DialogTitle>
|
||||
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
||||
This overwrites the current value — there is no undo.
|
||||
{t('bulk.desc', { n: ids.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-2 space-y-3">
|
||||
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Field</Label>
|
||||
<Label className="text-sm">{t('bulk.fieldLabel')}</Label>
|
||||
<Select value={field} onValueChange={setField}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{GROUPS.map((g) => (
|
||||
<div key={g}>
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
||||
{FIELDS.filter((f) => f.group === g).map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
||||
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Label className="text-sm">Value</Label>
|
||||
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
|
||||
{isStatus ? (
|
||||
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={textValue}
|
||||
placeholder="leave empty to clear the field"
|
||||
placeholder={t('bulk.clearPlaceholder')}
|
||||
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
||||
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||
</div>
|
||||
{error && <div className="text-xs text-rose-700">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('bulk.cancel')}</Button>
|
||||
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
Apply to {ids.length}
|
||||
{t('bulk.applyTo', { n: ids.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Star } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -26,6 +27,7 @@ function fmtDateTime(s: any): string {
|
||||
}
|
||||
|
||||
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
const { t } = useI18n();
|
||||
const hasCall = currentCall.trim() !== '';
|
||||
const count = wb?.count ?? 0;
|
||||
const entries = wb?.entries ?? [];
|
||||
@@ -35,7 +37,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Worked before
|
||||
{t('chp.workedBefore')}
|
||||
</span>
|
||||
{hasCall && (
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
||||
@@ -52,31 +54,31 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
|
||||
{!hasCall ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign to see prior contacts.
|
||||
{t('chp.typeCallsign')}
|
||||
</div>
|
||||
) : busy && count === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
{t('chp.checking')}
|
||||
</div>
|
||||
) : count === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-6 text-primary fill-current" />
|
||||
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with this callsign.</div>
|
||||
<div className="text-lg font-bold text-primary tracking-wider">{t('chp.new')}</div>
|
||||
<div>{t('chp.noPriorQso')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
{t('chp.first')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
{t('chp.last')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Date UTC</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Band</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Mode</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||
@@ -96,10 +98,10 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||
{e.lotw_rcvd === 'Y' && (
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title="LoTW rcvd">L</span>
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||
)}
|
||||
{e.qsl_rcvd === 'Y' && (
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title="Bureau rcvd">B</span>
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -108,7 +110,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
</table>
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
||||
+ {count - entries.length} older QSOs
|
||||
{t('chp.olderQsos', { n: count - entries.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
||||
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
||||
@@ -18,6 +19,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
||||
onSend: (text: string) => void; onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const me = (myCall || '').toUpperCase();
|
||||
@@ -34,7 +36,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
<div className="h-full flex flex-col 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 shrink-0">
|
||||
<MessageSquare className="size-4 text-sky-600" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
|
||||
<span className="flex-1" />
|
||||
{/* Online count — hover to see who's connected. */}
|
||||
<div className="relative group">
|
||||
@@ -43,7 +45,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
</span>
|
||||
{online.length > 0 && (
|
||||
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">Online</div>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">{t('chatp.online')}</div>
|
||||
{online.map((o) => (
|
||||
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
||||
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
||||
@@ -52,14 +54,14 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title={t('chatp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
||||
{msgs.length === 0 ? (
|
||||
<div className="text-muted-foreground italic text-center py-6">No messages yet.</div>
|
||||
<div className="text-muted-foreground italic text-center py-6">{t('chatp.noMessages')}</div>
|
||||
) : msgs.map((m) => {
|
||||
const mine = m.operator.toUpperCase() === me;
|
||||
return (
|
||||
@@ -79,7 +81,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="Message…"
|
||||
placeholder={t('chatp.messagePh')}
|
||||
maxLength={1000}
|
||||
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
@@ -117,27 +120,27 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{
|
||||
group: 'Spot', label: 'Time', colId: 'time',
|
||||
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Call', colId: 'call',
|
||||
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
||||
@@ -147,25 +150,25 @@ const COL_CATALOG: ColEntry[] = [
|
||||
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Status', colId: 'status',
|
||||
headerName: 'Status', width: 96, sortable: true,
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return 'NEW DXCC';
|
||||
if (s?.status === 'new-band') return 'NEW BAND';
|
||||
if (s?.status === 'new-mode') return 'NEW MODE';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||
return s?.worked_call ? 'WKD CALL' : '';
|
||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(statusFor(p));
|
||||
const b = statusBadge(t, statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{
|
||||
@@ -176,41 +179,41 @@ const COL_CATALOG: ColEntry[] = [
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
||||
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||
if (s?.worked_call) return 'Already worked this call';
|
||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#166534' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Freq', colId: 'freq',
|
||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Band', colId: 'band',
|
||||
headerName: 'Band', field: 'band' as any, width: 75,
|
||||
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||
: undefined),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Mode', colId: 'mode',
|
||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
||||
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
@@ -226,48 +229,48 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
|
||||
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
|
||||
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
||||
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
|
||||
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
||||
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
|
||||
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
|
||||
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
|
||||
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
|
||||
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Country', colId: 'country',
|
||||
headerName: 'Country', width: 140,
|
||||
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
|
||||
headerName: t('clg2.c.country'), width: 140,
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -275,8 +278,8 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Continent', colId: 'continent',
|
||||
headerName: 'Cont', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -284,50 +287,57 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
||||
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Source', colId: 'source',
|
||||
headerName: 'Source', field: 'source_name' as any, width: 100,
|
||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Comment', colId: 'comment',
|
||||
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Received at', colId: 'received_at',
|
||||
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Raw', colId: 'raw',
|
||||
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
|
||||
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['Spot', 'Geo'];
|
||||
|
||||
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
||||
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
||||
|
||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
}), [COL_CATALOG]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
@@ -404,11 +414,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
title={t('clg2.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('clg2.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
@@ -437,9 +447,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cluster columns</DialogTitle>
|
||||
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Cluster table.
|
||||
{t('clg2.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
@@ -449,10 +459,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
@@ -471,8 +481,8 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { ListContests, ContestStats } from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
|
||||
// so a running contest (serial counter included) survives a restart.
|
||||
@@ -49,6 +50,7 @@ export function ContestPanel({ session, onChange }: {
|
||||
session: ContestSession;
|
||||
onChange: (patch: Partial<ContestSession>) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [contests, setContests] = useState<Def[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
|
||||
@@ -84,14 +86,14 @@ export function ContestPanel({ session, onChange }: {
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||
<Trophy className="size-4 text-amber-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Contest setup</span>
|
||||
{session.active && <span className="ml-auto rounded bg-amber-500 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-white">Live</span>}
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
|
||||
{session.active && <span className="ml-auto rounded bg-amber-500 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-white">{t('ctp.live')}</span>}
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm flex-1 min-w-[220px]">
|
||||
<option value="">— choose a contest —</option>
|
||||
<option value="">{t('ctp.chooseContest')}</option>
|
||||
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||
</select>
|
||||
{session.code && <code className="text-xs text-muted-foreground">{session.code}</code>}
|
||||
@@ -103,14 +105,14 @@ export function ContestPanel({ session, onChange }: {
|
||||
<button key={x} type="button" onClick={() => onChange({ exchange: x })}
|
||||
className={cn('px-3 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
|
||||
session.exchange === x ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{x === 'serial' ? 'Serial' : 'Fixed exchange'}
|
||||
{x === 'serial' ? t('ctp.serial') : t('ctp.fixedExchange')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{session.exchange === 'serial' ? (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
Next serial
|
||||
{t('ctp.nextSerial')}
|
||||
<input type="number" min={1} value={session.nextSerial}
|
||||
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
|
||||
className="w-20 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||
@@ -122,7 +124,7 @@ export function ContestPanel({ session, onChange }: {
|
||||
{FIXED_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
<input value={session.sentFixed} onChange={(e) => onChange({ sentFixed: e.target.value.toUpperCase() })}
|
||||
placeholder="your exchange" className="w-32 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||
placeholder={t('ctp.yourExchangePh')} className="w-32 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -131,35 +133,35 @@ export function ContestPanel({ session, onChange }: {
|
||||
dupe check — so old runs of the same contest aren't included. */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
Start (UTC)
|
||||
{t('ctp.startUtc')}
|
||||
<input type="datetime-local" value={isoToUtcInput(session.startISO)}
|
||||
onChange={(e) => onChange({ startISO: utcInputToISO(e.target.value) })}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
End (UTC)
|
||||
{t('ctp.endUtc')}
|
||||
<input type="datetime-local" value={isoToUtcInput(session.endISO)}
|
||||
onChange={(e) => onChange({ endISO: utcInputToISO(e.target.value) })}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">empty end = open · leave blank to count all</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('ctp.windowHint')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{session.active ? (
|
||||
<button type="button" onClick={() => onChange({ active: false })}
|
||||
className="rounded-md border border-rose-500/60 bg-rose-500/10 px-4 py-1.5 text-sm font-bold text-rose-600 hover:bg-rose-500/20">
|
||||
Stop contest
|
||||
{t('ctp.stopContest')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" disabled={!session.code}
|
||||
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
|
||||
className="rounded-md bg-amber-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-40">
|
||||
Start contest
|
||||
{t('ctp.startContest')}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.active ? 'The entry form shows Snt/Rcv and a DUPE badge; QSOs are stamped with this contest.' : 'Pick a contest, set the window, then Start.'}
|
||||
{session.active ? t('ctp.activeHint') : t('ctp.inactiveHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,22 +171,22 @@ export function ContestPanel({ session, onChange }: {
|
||||
{session.active && (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Scoreboard</span>
|
||||
<span className="text-[10px] text-muted-foreground">estimate</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.scoreboard')}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t('ctp.estimate')}</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Stat label="QSOs" value={stats?.qsos ?? 0} />
|
||||
<Stat label={stats?.mult_label || 'Mult'} value={stats?.mult ?? 0} accent="#f59e0b" />
|
||||
<Stat label="Score (est.)" value={(stats?.score ?? 0).toLocaleString()} accent="#16a34a" />
|
||||
<Stat label="Last 60 min" value={stats?.last_hour ?? 0} />
|
||||
<Stat label={t('ctp.qsos')} value={stats?.qsos ?? 0} />
|
||||
<Stat label={stats?.mult_label || t('ctp.mult')} value={stats?.mult ?? 0} accent="#f59e0b" />
|
||||
<Stat label={t('ctp.scoreEst')} value={(stats?.score ?? 0).toLocaleString()} accent="#16a34a" />
|
||||
<Stat label={t('ctp.last60')} value={stats?.last_hour ?? 0} />
|
||||
</div>
|
||||
{stats && stats.by_band && stats.by_band.length > 0 && (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left px-3 py-1.5 font-bold">Band</th>
|
||||
<th className="text-right px-3 py-1.5 font-bold">QSOs</th>
|
||||
<th className="text-left px-3 py-1.5 font-bold">{t('ctp.band')}</th>
|
||||
<th className="text-right px-3 py-1.5 font-bold">{t('ctp.qsos')}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{stats.by_band.map((r) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
@@ -75,35 +76,35 @@ export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
||||
const PROP_MODES: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'AS', label: 'Aircraft Scatter' },
|
||||
{ value: 'AUR', label: 'Aurora' },
|
||||
{ value: 'AUE', label: 'Aurora-E' },
|
||||
{ value: 'BS', label: 'Back Scatter' },
|
||||
{ value: 'AS', label: 'detp.propAS' },
|
||||
{ value: 'AUR', label: 'detp.propAUR' },
|
||||
{ value: 'AUE', label: 'detp.propAUE' },
|
||||
{ value: 'BS', label: 'detp.propBS' },
|
||||
{ value: 'ECH', label: 'EchoLink' },
|
||||
{ value: 'EME', label: 'Earth-Moon-Earth' },
|
||||
{ value: 'ES', label: 'Sporadic E' },
|
||||
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
||||
{ value: 'F2', label: 'F2 Reflection' },
|
||||
{ value: 'GWAVE', label: 'Ground Wave' },
|
||||
{ value: 'INTERNET', label: 'Internet-assisted' },
|
||||
{ value: 'ION', label: 'Ionoscatter' },
|
||||
{ value: 'EME', label: 'detp.propEME' },
|
||||
{ value: 'ES', label: 'detp.propES' },
|
||||
{ value: 'FAI', label: 'detp.propFAI' },
|
||||
{ value: 'F2', label: 'detp.propF2' },
|
||||
{ value: 'GWAVE', label: 'detp.propGWAVE' },
|
||||
{ value: 'INTERNET', label: 'detp.propINTERNET' },
|
||||
{ value: 'ION', label: 'detp.propION' },
|
||||
{ value: 'IRL', label: 'IRLP' },
|
||||
{ value: 'LOS', label: 'Line of Sight' },
|
||||
{ value: 'MS', label: 'Meteor Scatter' },
|
||||
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
||||
{ value: 'RS', label: 'Rain Scatter' },
|
||||
{ value: 'SAT', label: 'Satellite' },
|
||||
{ value: 'TEP', label: 'Trans-Equatorial' },
|
||||
{ value: 'TR', label: 'Tropospheric Ducting' },
|
||||
{ value: 'LOS', label: 'detp.propLOS' },
|
||||
{ value: 'MS', label: 'detp.propMS' },
|
||||
{ value: 'RPT', label: 'detp.propRPT' },
|
||||
{ value: 'RS', label: 'detp.propRS' },
|
||||
{ value: 'SAT', label: 'detp.propSAT' },
|
||||
{ value: 'TEP', label: 'detp.propTEP' },
|
||||
{ value: 'TR', label: 'detp.propTR' },
|
||||
];
|
||||
|
||||
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
||||
const ANT_PATHS: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'S', label: 'Short Path' },
|
||||
{ value: 'L', label: 'Long Path' },
|
||||
{ value: 'G', label: 'Grayline' },
|
||||
{ value: 'O', label: 'Other' },
|
||||
{ value: 'S', label: 'detp.pathShort' },
|
||||
{ value: 'L', label: 'detp.pathLong' },
|
||||
{ value: 'G', label: 'detp.pathGrayline' },
|
||||
{ value: 'O', label: 'detp.pathOther' },
|
||||
];
|
||||
|
||||
function numOrUndef(v: string): number | undefined {
|
||||
@@ -123,6 +124,7 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
@@ -191,11 +193,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}
|
||||
|
||||
const tabs: { key: TabName; label: string }[] = [
|
||||
{ key: 'stats', label: `Stats (${fk}1)` },
|
||||
{ key: 'info', label: `Info (${fk}2)` },
|
||||
{ key: 'awards', label: `Awards (${fk}3)` },
|
||||
{ key: 'my', label: `My (${fk}4)` },
|
||||
{ key: 'extended', label: `Extended (${fk}5)` },
|
||||
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
|
||||
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
|
||||
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
|
||||
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
|
||||
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -226,27 +228,27 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="State / pref">
|
||||
<Field label={t('detp.statePref')}>
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="County">
|
||||
<Field label={t('detp.county')}>
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Prefix">
|
||||
<Field label={t('detp.prefix')}>
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
<Field label="CQ zone">
|
||||
<Field label={t('detp.cqZone')}>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</Field>
|
||||
<Field label="ITU zone">
|
||||
<Field label={t('detp.ituZone')}>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</Field>
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label="DXCC #">
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -255,7 +257,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Azimuth LP">
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -264,7 +266,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance SP">
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -273,7 +275,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance LP">
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -282,13 +284,13 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Address" span={3}>
|
||||
<Field label={t('detp.address')} span={3}>
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL message" span={3}>
|
||||
<Field label={t('detp.qslMessage')} span={3}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL via" span={2}>
|
||||
<Field label={t('detp.qslVia')} span={2}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -305,7 +307,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||
{detected.map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||
@@ -318,49 +320,49 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{open === 'my' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Azimuth (°)">
|
||||
<Field label={t('detp.azimuth')}>
|
||||
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Elevation (°)">
|
||||
<Field label={t('detp.elevation')}>
|
||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="TX power (W)">
|
||||
<Field label={t('detp.txPower')}>
|
||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<div className="col-span-3 flex items-end pb-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||
Satellite mode
|
||||
{t('detp.satelliteMode')}
|
||||
</label>
|
||||
</div>
|
||||
<Field label="Ant. path" span={2}>
|
||||
<Field label={t('detp.antPath')} span={2}>
|
||||
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Propagation" span={4}>
|
||||
<Field label={t('detp.propagation')} span={4}>
|
||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Rig" span={3}>
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Antenna" span={3}>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label="Satellite name" span={3}>
|
||||
<Field label={t('detp.satName')} span={3}>
|
||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Satellite mode" span={3}>
|
||||
<Field label={t('detp.satelliteMode')} span={3}>
|
||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
@@ -370,16 +372,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{open === 'extended' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Contest ID" span={2}>
|
||||
<Field label={t('detp.contestId')} span={2}>
|
||||
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="SRX">
|
||||
<Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
|
||||
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="STX">
|
||||
<Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Contacted email" span={3}>
|
||||
<Field label={t('detp.contactedEmail')} span={3}>
|
||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 };
|
||||
@@ -17,19 +18,20 @@ type Props = {
|
||||
// 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">Voice keyer</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
||||
{status.playing && <span className="text-[10px] text-amber-600 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" /> Stop
|
||||
<Square className="size-3" /> {t('dvkp.stop')}
|
||||
</Button>
|
||||
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
|
||||
<button className="text-muted-foreground hover:text-foreground" title={t('dvkp.disable')} onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -38,7 +40,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
{!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" />
|
||||
No messages recorded yet. Open <strong>Settings → Audio devices & voice keyer</strong> to record F1–F6.
|
||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
@@ -48,7 +50,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
type="button"
|
||||
disabled={!m.has_audio}
|
||||
onClick={() => onPlay(m.slot)}
|
||||
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
|
||||
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
|
||||
@@ -57,7 +59,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
)}
|
||||
>
|
||||
<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 ? 'message' : '—')}</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>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
|
||||
// adds field/operator/value conditions, joins them with AND or OR, and can
|
||||
@@ -27,83 +28,83 @@ export interface QueryFilter {
|
||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'Callsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
|
||||
{ value: 'band', label: 'Band', type: 'text' },
|
||||
{ value: 'band_rx', label: 'RX band', type: 'text' },
|
||||
{ value: 'mode', label: 'Mode', type: 'text' },
|
||||
{ value: 'submode', label: 'Submode', type: 'text' },
|
||||
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
|
||||
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
|
||||
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
|
||||
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
|
||||
{ value: 'name', label: 'Name', type: 'text' },
|
||||
{ value: 'qth', label: 'QTH', type: 'text' },
|
||||
{ value: 'address', label: 'Address', type: 'text' },
|
||||
{ value: 'email', label: 'E-mail', type: 'text' },
|
||||
{ value: 'grid', label: 'Grid', type: 'text' },
|
||||
{ value: 'country', label: 'Country', type: 'text' },
|
||||
{ value: 'state', label: 'State', type: 'text' },
|
||||
{ value: 'cnty', label: 'County', type: 'text' },
|
||||
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
|
||||
{ value: 'cont', label: 'Continent', type: 'text' },
|
||||
{ value: 'cqz', label: 'CQ zone', type: 'number' },
|
||||
{ value: 'ituz', label: 'ITU zone', type: 'number' },
|
||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
||||
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
||||
{ value: 'wwff_ref', label: 'WWFF ref', type: 'text' },
|
||||
{ value: 'rig', label: 'Rig', type: 'text' },
|
||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
||||
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
||||
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
|
||||
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
|
||||
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
|
||||
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
|
||||
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' },
|
||||
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
||||
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
|
||||
{ value: 'sat_name', label: 'Satellite', type: 'text' },
|
||||
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
|
||||
{ value: 'operator', label: 'Operator', type: 'text' },
|
||||
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
||||
{ value: 'my_country', label: 'My country', type: 'text' },
|
||||
{ value: 'my_state', label: 'My state', type: 'text' },
|
||||
{ value: 'my_cnty', label: 'My county', type: 'text' },
|
||||
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
|
||||
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
|
||||
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
|
||||
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
|
||||
{ value: 'my_street', label: 'My street', type: 'text' },
|
||||
{ value: 'my_city', label: 'My city', type: 'text' },
|
||||
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
|
||||
{ value: 'my_rig', label: 'My rig', type: 'text' },
|
||||
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
|
||||
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||
{ value: 'mode', label: 'fltb.fMode', type: 'text' },
|
||||
{ value: 'submode', label: 'fltb.fSubmode', type: 'text' },
|
||||
{ value: 'freq_hz', label: 'fltb.fFreq', type: 'number' },
|
||||
{ value: 'freq_rx_hz', label: 'fltb.fRxFreq', type: 'number' },
|
||||
{ value: 'rst_sent', label: 'fltb.fRstSent', type: 'text' },
|
||||
{ value: 'rst_rcvd', label: 'fltb.fRstRcvd', type: 'text' },
|
||||
{ value: 'name', label: 'fltb.fName', type: 'text' },
|
||||
{ value: 'qth', label: 'fltb.fQth', type: 'text' },
|
||||
{ value: 'address', label: 'fltb.fAddress', type: 'text' },
|
||||
{ value: 'email', label: 'fltb.fEmail', type: 'text' },
|
||||
{ value: 'grid', label: 'fltb.fGrid', type: 'text' },
|
||||
{ value: 'country', label: 'fltb.fCountry', type: 'text' },
|
||||
{ value: 'state', label: 'fltb.fState', type: 'text' },
|
||||
{ value: 'cnty', label: 'fltb.fCounty', type: 'text' },
|
||||
{ value: 'dxcc', label: 'fltb.fDxcc', type: 'number' },
|
||||
{ value: 'cont', label: 'fltb.fContinent', type: 'text' },
|
||||
{ value: 'cqz', label: 'fltb.fCqz', type: 'number' },
|
||||
{ value: 'ituz', label: 'fltb.fItuz', type: 'number' },
|
||||
{ value: 'iota', label: 'fltb.fIota', type: 'text' },
|
||||
{ value: 'sota_ref', label: 'fltb.fSota', type: 'text' },
|
||||
{ value: 'pota_ref', label: 'fltb.fPota', type: 'text' },
|
||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
{ value: 'prop_mode', label: 'fltb.fPropMode', type: 'text' },
|
||||
{ value: 'sat_name', label: 'fltb.fSatellite', type: 'text' },
|
||||
{ value: 'station_callsign', label: 'fltb.fMyCallsign', type: 'text' },
|
||||
{ value: 'operator', label: 'fltb.fOperator', type: 'text' },
|
||||
{ value: 'owner_callsign', label: 'fltb.fOwnerCallsign', type: 'text' },
|
||||
{ value: 'my_grid', label: 'fltb.fMyGrid', type: 'text' },
|
||||
{ value: 'my_country', label: 'fltb.fMyCountry', type: 'text' },
|
||||
{ value: 'my_state', label: 'fltb.fMyState', type: 'text' },
|
||||
{ value: 'my_cnty', label: 'fltb.fMyCounty', type: 'text' },
|
||||
{ value: 'my_iota', label: 'fltb.fMyIota', type: 'text' },
|
||||
{ value: 'my_sota_ref', label: 'fltb.fMySota', type: 'text' },
|
||||
{ value: 'my_pota_ref', label: 'fltb.fMyPota', type: 'text' },
|
||||
{ value: 'my_wwff_ref', label: 'fltb.fMyWwff', type: 'text' },
|
||||
{ value: 'my_street', label: 'fltb.fMyStreet', type: 'text' },
|
||||
{ value: 'my_city', label: 'fltb.fMyCity', type: 'text' },
|
||||
{ value: 'my_postal_code', label: 'fltb.fMyPostal', type: 'text' },
|
||||
{ value: 'my_rig', label: 'fltb.fMyRig', type: 'text' },
|
||||
{ value: 'my_antenna', label: 'fltb.fMyAntenna', type: 'text' },
|
||||
{ value: 'tx_pwr', label: 'fltb.fTxPower', type: 'number' },
|
||||
{ value: 'comment', label: 'fltb.fComment', type: 'text' },
|
||||
{ value: 'notes', label: 'fltb.fNotes', type: 'text' },
|
||||
];
|
||||
|
||||
const OPS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'eq', label: 'equals (=)' },
|
||||
{ value: 'ne', label: 'not equal (≠)' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'startswith', label: 'starts with' },
|
||||
{ value: 'endswith', label: 'ends with' },
|
||||
{ value: 'gt', label: 'greater than (>)' },
|
||||
{ value: 'lt', label: 'less than (<)' },
|
||||
{ value: 'ge', label: 'greater or equal (≥)' },
|
||||
{ value: 'le', label: 'less or equal (≤)' },
|
||||
{ value: 'empty', label: 'is empty' },
|
||||
{ value: 'notempty', label: 'is not empty' },
|
||||
{ value: 'eq', label: 'fltb.opEq' },
|
||||
{ value: 'ne', label: 'fltb.opNe' },
|
||||
{ value: 'contains', label: 'fltb.opContains' },
|
||||
{ value: 'startswith', label: 'fltb.opStartsWith' },
|
||||
{ value: 'endswith', label: 'fltb.opEndsWith' },
|
||||
{ value: 'gt', label: 'fltb.opGt' },
|
||||
{ value: 'lt', label: 'fltb.opLt' },
|
||||
{ value: 'ge', label: 'fltb.opGe' },
|
||||
{ value: 'le', label: 'fltb.opLe' },
|
||||
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
||||
];
|
||||
|
||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
||||
@@ -132,6 +133,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
||||
@@ -184,25 +186,25 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader className="px-6 py-4">
|
||||
<DialogTitle>QSO filter</DialogTitle>
|
||||
<DialogTitle>{t('fltb.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-4 space-y-5">
|
||||
{/* Match mode + presets */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Match</span>
|
||||
<span className="text-muted-foreground">{t('fltb.match')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{(['AND', 'OR'] as const).map((m) => (
|
||||
<button key={m} type="button" onClick={() => setMatch(m)}
|
||||
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
|
||||
{m === 'AND' ? 'ALL (AND)' : 'ANY (OR)'}
|
||||
{m === 'AND' ? t('fltb.all') : t('fltb.any')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{presetNames.length > 0 && (
|
||||
<Select onValueChange={loadPreset}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder="Load preset…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder={t('fltb.loadPreset')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{presetNames.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
@@ -220,14 +222,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
{/* Conditions */}
|
||||
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
|
||||
{conditions.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground py-4 text-center">No conditions — the list shows all QSOs. Add one below.</div>
|
||||
<div className="text-xs text-muted-foreground py-4 text-center">{t('fltb.noConditions')}</div>
|
||||
)}
|
||||
{conditions.map((c, i) => {
|
||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? t('fltb.where') : match}</span>
|
||||
<Select value={c.field} onValueChange={(v) => {
|
||||
// Reset op if the new field type doesn't allow the current one.
|
||||
const allowed = opsFor(v).map((o) => o.value);
|
||||
@@ -235,50 +237,50 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
}}>
|
||||
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}
|
||||
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{t(f.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{t(o.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title="Remove">
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
|
||||
<Plus className="size-3.5 mr-1" /> Add condition
|
||||
<Plus className="size-3.5 mr-1" /> {t('fltb.addCondition')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Save preset */}
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<Input className="h-8 w-56 text-xs" placeholder="Preset name…" value={presetName}
|
||||
<Input className="h-8 w-56 text-xs" placeholder={t('fltb.presetNamePh')} value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
|
||||
<Save className="size-3.5 mr-1" /> Save preset
|
||||
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
||||
<Button variant="ghost" onClick={() => { setConditions([]); }}>Clear</Button>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={apply}>Apply & close</Button>
|
||||
<Button variant="ghost" onClick={() => { setConditions([]); }}>{t('fltb.clear')}</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('fltb.cancel')}</Button>
|
||||
<Button onClick={apply}>{t('fltb.applyClose')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
|
||||
@@ -13,6 +14,7 @@ type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
// (no callsign configured yet). It writes straight into the active profile, so
|
||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [p, setP] = useState<Profile | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -68,38 +70,38 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Radio className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
|
||||
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
||||
{t('frm.intro')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
|
||||
<Label className="text-sm">{t('frm.callsign')} <span className="text-red-500">*</span></Label>
|
||||
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
|
||||
<Label className="text-sm">{t('frm.locator')} <span className="text-red-500">*</span></Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Operator</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||
<Label className="text-sm">{t('frm.operator')}</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder={t('frm.operatorPh')} value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Owner</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||
<Label className="text-sm">{t('frm.owner')}</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder={t('frm.ownerPh')} value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Name</Label>
|
||||
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||
<Label className="text-sm">{t('frm.name')}</Label>
|
||||
<Input className="h-9" placeholder={t('frm.namePh')} value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium">Award reference lists</div>
|
||||
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
||||
<div className="font-medium">{t('frm.awardRefs')}</div>
|
||||
<div className="text-muted-foreground">{t('frm.awardRefsHint')}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
|
||||
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
|
||||
</Button>
|
||||
</div>
|
||||
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
|
||||
@@ -108,8 +110,8 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
|
||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
|
||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? t('frm.saving') : t('frm.startLogging')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FlexState = {
|
||||
available: boolean; model?: string;
|
||||
@@ -190,6 +191,7 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||
export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
const hold = useRef<Record<string, number>>({});
|
||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||
@@ -264,34 +266,34 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
<Radio className="size-6 text-sky-400" />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
|
||||
<span className="text-[11px] text-slate-400">SmartSDR remote control</span>
|
||||
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||
!st.available ? 'bg-slate-700 text-slate-300'
|
||||
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
|
||||
<span className="size-2 rounded-full bg-current" />
|
||||
{!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
|
||||
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{off && (
|
||||
<div className="text-center text-sm text-muted-foreground py-6">
|
||||
Waiting for the FlexRadio… (set CAT to FlexRadio and connect)
|
||||
{t('flxp.waiting')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TX + RX columns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* TRANSMIT */}
|
||||
<Card icon={Zap} title="Transmit" accent="#dc2626">
|
||||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</span>
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
||||
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">Tune Pwr</span>
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.tunePwr')}</span>
|
||||
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
|
||||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
|
||||
</div>
|
||||
@@ -311,7 +313,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" disabled={off}
|
||||
title="Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR."
|
||||
title={t('flxp.splitHint')}
|
||||
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
|
||||
@@ -338,7 +340,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
||||
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">VOX Dly</span>
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">{t('flxp.voxDly')}</span>
|
||||
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
|
||||
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
|
||||
@@ -356,17 +358,17 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.speed')}</span>
|
||||
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.pitch')}</span>
|
||||
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.delay')}</span>
|
||||
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||||
</div>
|
||||
@@ -378,7 +380,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
</Card>
|
||||
|
||||
{/* RECEIVE */}
|
||||
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||
@@ -406,7 +408,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||
<button type="button" disabled={rxOff}
|
||||
title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
|
||||
title={st.mute ? t('flxp.muted') : t('flxp.mute')}
|
||||
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||||
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||||
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
|
||||
@@ -440,7 +442,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||||
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{CW_BW.map((bw) => (
|
||||
<button key={bw} type="button" disabled={rxOff}
|
||||
@@ -458,7 +460,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
{!isCW && (
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{SSB_BW.map((bw) => (
|
||||
<button key={bw} type="button" disabled={rxOff}
|
||||
@@ -485,7 +487,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||
{st.amp_available && (
|
||||
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||
@@ -494,41 +496,41 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
{(pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? 'PowerGenius connected' : (pg.last_error || 'PowerGenius offline')}>
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
|
||||
<span className="text-muted-foreground">Fan</span>
|
||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||||
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
|
||||
>
|
||||
<option value="STANDARD">Standard</option>
|
||||
<option value="CONTEST">Contest</option>
|
||||
<option value="BROADCAST">Broadcast</option>
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||||
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
|
||||
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Live meters (UDP VITA-49 stream) */}
|
||||
<Card icon={Gauge} title="Meters">
|
||||
<Card icon={Gauge} title={t('flxp.meters')}>
|
||||
{(() => {
|
||||
const meters = st.meters || [];
|
||||
if (off || meters.length === 0) {
|
||||
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet — waiting for the radio's UDP stream…</p>;
|
||||
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
|
||||
}
|
||||
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
@@ -568,7 +570,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||
{amp.length > 0 && (
|
||||
<div className="border-t border-border/60 pt-2 mt-1">
|
||||
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">AMPLIFIER</div>
|
||||
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{amp.map((m) => {
|
||||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type IcomState = {
|
||||
available: boolean; model?: string; mode?: string;
|
||||
@@ -151,6 +152,7 @@ function sUnit(v: number): string {
|
||||
// reassembled sweep on a canvas. The amplitude array is raw rig scale (~0-160);
|
||||
// we normalise to the tallest recent peak so the trace fills the height.
|
||||
function ScopePanadapter() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(false);
|
||||
const [fixed, setFixed] = useState(true);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -288,10 +290,10 @@ function ScopePanadapter() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card icon={Activity} title="Spectrum" accent="#38bdf8">
|
||||
<Card icon={Activity} title={t('icmp.spectrum')} accent="#38bdf8">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground truncate">
|
||||
{on ? (fixed ? 'Fixed — double-click / wheel to tune' : 'Center — follows VFO') : 'Scope off'}
|
||||
{on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{on && (
|
||||
@@ -314,6 +316,7 @@ function ScopePanadapter() {
|
||||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||||
export function IcomPanel() {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<IcomState>(ZERO);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tuning, setTuning] = useState(false);
|
||||
@@ -355,7 +358,7 @@ export function IcomPanel() {
|
||||
if (!st.available) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||
Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.
|
||||
{t('icmp.notConnected')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -379,7 +382,7 @@ export function IcomPanel() {
|
||||
</div>
|
||||
<button type="button" onClick={refresh} disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -388,7 +391,7 @@ export function IcomPanel() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* Live meters. */}
|
||||
<Card icon={Zap} title="Meters" accent="#f59e0b">
|
||||
<Card icon={Zap} title={t('icmp.meters')} accent="#f59e0b">
|
||||
{tx ? (
|
||||
<>
|
||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
|
||||
@@ -400,12 +403,12 @@ export function IcomPanel() {
|
||||
</Card>
|
||||
|
||||
{/* Transmit controls. */}
|
||||
<Card icon={Mic} title="Transmit" accent="#ef4444">
|
||||
<Row label="Power">
|
||||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||||
<Row label={t('icmp.power')}>
|
||||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
||||
</Row>
|
||||
<Row label="Mic">
|
||||
<Row label={t('icmp.mic')}>
|
||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||
</Row>
|
||||
@@ -424,7 +427,7 @@ export function IcomPanel() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card icon={Radio} title="Receive" accent="#2563eb">
|
||||
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
||||
<Row label="AF">
|
||||
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||
@@ -437,7 +440,7 @@ export function IcomPanel() {
|
||||
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||
</Row>
|
||||
<Row label="Preamp">
|
||||
<Row label={t('icmp.preamp')}>
|
||||
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||
</Row>
|
||||
@@ -445,13 +448,13 @@ export function IcomPanel() {
|
||||
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label="Filter">
|
||||
<Row label={t('icmp.filter')}>
|
||||
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||||
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
|
||||
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
||||
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||
@@ -460,7 +463,7 @@ export function IcomPanel() {
|
||||
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||
<span className="text-xs text-muted-foreground">Auto notch filter</span>
|
||||
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { QSOForm } from '@/types';
|
||||
import {
|
||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||
@@ -66,6 +67,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [nets, setNets] = useState<Net[]>([]);
|
||||
const [selId, setSelId] = useState<string>('');
|
||||
const [openId, setOpenId] = useState<string>('');
|
||||
@@ -123,7 +125,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
);
|
||||
|
||||
async function newNet() {
|
||||
const name = window.prompt('New NET name:');
|
||||
const name = window.prompt(t('ncp.newNetPrompt'));
|
||||
if (!name?.trim()) return;
|
||||
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
@@ -131,7 +133,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
async function renameNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
const name = window.prompt('Rename NET:', cur?.name ?? '');
|
||||
const name = window.prompt(t('ncp.renamePrompt'), cur?.name ?? '');
|
||||
if (!name?.trim()) return;
|
||||
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
@@ -139,7 +141,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
async function deleteNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return;
|
||||
if (!window.confirm(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) return;
|
||||
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
@@ -147,7 +149,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
try {
|
||||
if (isOpen) {
|
||||
if (active.length > 0 &&
|
||||
!window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return;
|
||||
!window.confirm(t('ncp.closeConfirm', { n: active.length }))) return;
|
||||
await NetClose(); setOpenId('');
|
||||
} else {
|
||||
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
||||
@@ -206,7 +208,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
async function removeSelectedRoster() {
|
||||
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
||||
if (sel.length === 0) return;
|
||||
if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return;
|
||||
if (!window.confirm(t('ncp.removeConfirm', { n: sel.length }))) return;
|
||||
try {
|
||||
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
||||
await refreshRoster(selId);
|
||||
@@ -214,23 +216,23 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
}
|
||||
|
||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: 'Name', field: 'name', flex: 1 },
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||
{ headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'Mode', field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
|
||||
], []);
|
||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
||||
], [t]);
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: 'Name', field: 'name', flex: 1 },
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: 'Country', field: 'country', width: 130 },
|
||||
], []);
|
||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||
], [t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||
|
||||
@@ -239,31 +241,31 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||
<Plus className="size-3.5" /> New NET
|
||||
<Plus className="size-3.5" /> {t('ncp.newNet')}
|
||||
</Button>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||
value={selId}
|
||||
disabled={isOpen}
|
||||
onChange={(e) => setSelId(e.target.value)}
|
||||
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
|
||||
title={isOpen ? t('ncp.closeToSwitch') : t('ncp.selectNetTitle')}
|
||||
>
|
||||
<option value="">— select a NET —</option>
|
||||
<option value="">{t('ncp.selectNetOption')}</option>
|
||||
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||
</select>
|
||||
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
|
||||
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
|
||||
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
On air: <strong className="text-foreground">{active.length}</strong> ·
|
||||
Roster: <strong className="text-foreground">{roster.length}</strong>
|
||||
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
||||
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
||||
</span>
|
||||
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||
</div>
|
||||
@@ -272,8 +274,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{/* ACTIVE USERS (left) */}
|
||||
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||
On air — active QSOs
|
||||
<span className="ml-auto font-normal normal-case opacity-90">double-click → edit all fields · "Log & end" to save</span>
|
||||
{t('ncp.onAirActive')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
@@ -294,7 +296,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||
<MinusCircle className="size-3.5" /> Log & end selected
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -303,8 +305,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{/* NET USERS / roster (right) */}
|
||||
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||
NET users — roster
|
||||
<span className="ml-auto font-normal normal-case opacity-90">double-click → put on air</span>
|
||||
{t('ncp.netUsersRoster')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
@@ -323,15 +325,15 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||
<UserPlus className="size-3.5" /> Add contact
|
||||
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
||||
<MinusCircle className="size-3.5" /> Remove
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
||||
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||
<PlusCircle className="size-3.5" /> Put selected on air
|
||||
<PlusCircle className="size-3.5" /> {t('ncp.putOnAir')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -356,23 +358,23 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add contact to NET</DialogTitle>
|
||||
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
|
||||
<DialogTitle>{t('ncp.addContactTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('ncp.addContactDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 px-5 py-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[11px]">Callsign</Label>
|
||||
<Label className="text-[11px]">{t('ncp.callsign')}</Label>
|
||||
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||
<Search className="size-3.5" /> {looking ? '…' : 'Search'}
|
||||
<Search className="size-3.5" /> {looking ? '…' : t('ncp.search')}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Name</Label>
|
||||
<Label className="text-[11px]">{t('ncp.name')}</Label>
|
||||
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -380,14 +382,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Country</Label>
|
||||
<Label className="text-[11px]">{t('ncp.country')}</Label>
|
||||
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>{t('ncp.cancel')}</Button>
|
||||
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||
<PlusCircle className="size-3.5" /> Save in NET
|
||||
<PlusCircle className="size-3.5" /> {t('ncp.saveInNet')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Band = { band: string; is_default: boolean };
|
||||
type Antenna = {
|
||||
@@ -38,6 +39,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function OperatingPanel({ bands, onError }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [tree, setTree] = useState<Station[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// expanded keeps which stations show their antennas; everything open by
|
||||
@@ -131,7 +133,7 @@ export function OperatingPanel({ bands, onError }: Props) {
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeAntenna(stationId: number, antId: number) {
|
||||
if (!confirm('Delete this antenna?')) return;
|
||||
if (!confirm(t('op.deleteAntConfirm'))) return;
|
||||
try {
|
||||
await DeleteOperatingAntenna(antId);
|
||||
setTree((prev) => prev.map((s) =>
|
||||
@@ -149,23 +151,18 @@ export function OperatingPanel({ bands, onError }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
Define your rigs (stations) and the antennas connected to each one.
|
||||
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
|
||||
the default antenna for that band — when you change the band in the
|
||||
entry strip, the matching rig + antenna auto-fill the MY_RIG and
|
||||
MY_ANTENNA ADIF fields. Only one antenna can be the default per
|
||||
band; setting one clears the previous default.
|
||||
{t('op.intro1')}<Star className="inline size-3 text-amber-500 fill-current align-text-bottom" />{t('op.intro2')}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={addStation}>
|
||||
<Plus className="size-3.5" /> Add rig
|
||||
<Plus className="size-3.5" /> {t('op.addRig')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
|
||||
No rig configured yet. Click "Add rig" to get started.
|
||||
{t('op.noRig')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -212,6 +209,7 @@ function StationRow({
|
||||
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
||||
onUpdateAntenna, onDeleteAntenna,
|
||||
}: StationRowProps) {
|
||||
const { t } = useI18n();
|
||||
const editKey = `station:${station.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({
|
||||
@@ -242,7 +240,7 @@ function StationRow({
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
className="p-0.5 hover:bg-accent/40 rounded"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? t('op.collapse') : t('op.expand')}
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
@@ -253,12 +251,12 @@ function StationRow({
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Rig name (also stamped as MY_RIG)"
|
||||
placeholder={t('op.rigNamePh')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
|
||||
<Label className="text-[11px] text-muted-foreground">{t('op.powerW')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -269,8 +267,8 @@ function StationRow({
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commit}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
<Button size="sm" onClick={commit}>{t('btn.save')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -279,13 +277,13 @@ function StationRow({
|
||||
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title={t('op.edit')}>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
||||
<Plus className="size-3" /> Antenna
|
||||
<Plus className="size-3" /> {t('op.antenna')}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title={t('op.deleteRig')}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -296,7 +294,7 @@ function StationRow({
|
||||
<div className="p-2 space-y-2">
|
||||
{(station.antennas ?? []).length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic pl-6">
|
||||
No antenna yet — click "Antenna" above to add one.
|
||||
{t('op.noAntenna')}
|
||||
</div>
|
||||
) : (
|
||||
(station.antennas ?? []).map((a) => (
|
||||
@@ -329,6 +327,7 @@ type AntennaRowProps = {
|
||||
};
|
||||
|
||||
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
||||
const { t } = useI18n();
|
||||
const editKey = `antenna:${antenna.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({ name: antenna.name });
|
||||
@@ -377,13 +376,13 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Antenna name (also stamped as MY_ANTENNA)"
|
||||
placeholder={t('op.antNamePh')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commitNames}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
<Button size="sm" onClick={commitNames}>{t('btn.save')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -434,11 +433,11 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
? 'bg-amber-400 text-white'
|
||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
||||
)}
|
||||
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
|
||||
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
|
||||
>
|
||||
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider">
|
||||
{isDefault ? 'Default' : 'Set'}
|
||||
{isDefault ? t('op.default') : t('op.set')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHun
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
@@ -53,22 +54,30 @@ const SERVICES = [
|
||||
{ v: 'paper', label: 'Paper QSL' },
|
||||
];
|
||||
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
{ v: '_', label: '— leave —' },
|
||||
{ v: 'Y', label: 'Yes' },
|
||||
{ v: 'N', label: 'No' },
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'I', label: 'Ignore' },
|
||||
{ v: '_', label: 'qslm.leave' },
|
||||
{ v: 'Y', label: 'qslm.yes' },
|
||||
{ v: 'N', label: 'qslm.no' },
|
||||
{ v: 'R', label: 'qslm.requested' },
|
||||
{ v: 'I', label: 'qslm.ignore' },
|
||||
];
|
||||
|
||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
||||
const QSL_VIA_OPTIONS = [
|
||||
{ v: '_', label: '— leave —' },
|
||||
{ v: 'Bureau', label: 'Bureau' },
|
||||
{ v: 'Direct', label: 'Direct' },
|
||||
{ v: 'Electronic', label: 'Electronic' },
|
||||
{ v: '_', label: 'qslm.leave' },
|
||||
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
||||
{ v: 'Direct', label: 'qslm.viaDirect' },
|
||||
{ v: 'Electronic', label: 'qslm.viaElectronic' },
|
||||
];
|
||||
|
||||
// Maps a service value → its i18n label key (only for services with
|
||||
// translatable names; brand names like QRZ.com stay as-is in SERVICES).
|
||||
const SERVICE_LABEL_KEYS: Record<string, string> = {
|
||||
pota: 'qslm.svcPota',
|
||||
paper: 'qslm.svcPaper',
|
||||
};
|
||||
|
||||
type LogQSO = {
|
||||
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
||||
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
||||
@@ -77,13 +86,14 @@ type LogQSO = {
|
||||
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
||||
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
|
||||
|
||||
// label holds an i18n key.
|
||||
const SENT_STATUSES = [
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'N', label: 'No' },
|
||||
{ v: 'Q', label: 'Queued' },
|
||||
{ v: 'Y', label: 'Yes (already sent)' },
|
||||
{ v: 'I', label: 'Invalid' },
|
||||
{ v: '_', label: '— blank —' },
|
||||
{ v: 'R', label: 'qslm.sentRequested' },
|
||||
{ v: 'N', label: 'qslm.sentNo' },
|
||||
{ v: 'Q', label: 'qslm.sentQueued' },
|
||||
{ v: 'Y', label: 'qslm.sentYes' },
|
||||
{ v: 'I', label: 'qslm.sentInvalid' },
|
||||
{ v: '_', label: 'qslm.sentBlank' },
|
||||
];
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
@@ -109,6 +119,7 @@ export function fmtQslDate(s?: string): string {
|
||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [service, setService] = useState('lotw');
|
||||
// The callsign this profile signs/uploads/downloads as for the selected
|
||||
// service (Force station callsign, else the profile call). Shown so the user
|
||||
@@ -178,7 +189,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
notes: qslNotes,
|
||||
comment: qslComment,
|
||||
} as any);
|
||||
setPaperMsg(`${n} QSO updated.`);
|
||||
setPaperMsg(t('qslm.qsoUpdated', { n }));
|
||||
await searchPaper();
|
||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
||||
finally { setPaperBusy(false); }
|
||||
@@ -287,18 +298,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.service')}</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{SERVICE_LABEL_KEYS[s.v] ? t(SERVICE_LABEL_KEYS[s.v]) : s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{uploadCall && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||
<span
|
||||
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
||||
title={t('qslm.callsignScopeTitle')}
|
||||
>
|
||||
{uploadCall}
|
||||
</span>
|
||||
@@ -308,81 +319,81 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<>
|
||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||
Sync hunter log
|
||||
{t('qslm.syncHunterLog')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.onlyMyCallTitle')}>
|
||||
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
||||
Only my profile callsign
|
||||
{t('qslm.onlyMyCall')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.addMissingTitle')}>
|
||||
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||
Add not-found QSOs to my log
|
||||
{t('qslm.addMissing')}
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground self-center">Token in Settings → External services → POTA.</span>
|
||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.potaToken')}</span>
|
||||
</>
|
||||
) : service === 'paper' ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
|
||||
onChange={(e) => setPaperCall(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
||||
placeholder="e.g. DL1ABC" />
|
||||
placeholder={t('qslm.callsignPlaceholder')} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
|
||||
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Search
|
||||
{t('qslm.search')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
|
||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.sentStatus')}</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
{t('qslm.selectRequired')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{service === 'pota' && potaRes && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched}
|
||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||
</span>
|
||||
)}
|
||||
{service === 'paper' && paperRows.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
|
||||
<span className="text-xs text-muted-foreground">{t('qslm.paperCount', { total: paperRows.length, selected: paperSel.size })}</span>
|
||||
)}
|
||||
{!showLog && viewMode === 'confirmations' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.filter')}</label>
|
||||
<Select value={confFilter} onValueChange={setConfFilter}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="new">New (any)</SelectItem>
|
||||
<SelectItem value="dxcc">New DXCC</SelectItem>
|
||||
<SelectItem value="band">New band</SelectItem>
|
||||
<SelectItem value="slot">New slot</SelectItem>
|
||||
<SelectItem value="all">{t('qslm.filterAll')}</SelectItem>
|
||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{logLines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
||||
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
|
||||
{showLog ? <><ListChecks className="size-3.5" /> {t('qslm.results')}</> : <><ScrollText className="size-3.5" /> {t('qslm.log')}</>}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewMode === 'confirmations'
|
||||
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
|
||||
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -392,7 +403,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
|
||||
{service === 'paper' ? (
|
||||
paperRows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.paperEmpty')}</div>
|
||||
) : (
|
||||
// Same grid as Recent QSOs (full columns + column picker). Selection
|
||||
// drives which QSOs the apply-form below updates; a search selects all.
|
||||
@@ -409,25 +420,25 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<div className="space-y-3">
|
||||
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
|
||||
{!potaRes && !potaErr && !potaSyncing && (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Click “Sync hunter log” to fetch your pota.app log and stamp park references.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.potaEmpty')}</div>
|
||||
)}
|
||||
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> Syncing with pota.app…</div>}
|
||||
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
|
||||
{potaRes && (
|
||||
<>
|
||||
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
||||
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
|
||||
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
|
||||
{potaRes.skipped_other_call > 0 && (
|
||||
<> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}.</>
|
||||
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
||||
)}
|
||||
{' '}Rescan the POTA award to count the new references.
|
||||
{' '}{t('qslm.potaRescan')}
|
||||
</div>
|
||||
{potaRes.unmatched_list?.length > 0 && (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Activator</th><th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
|
||||
<th className="py-1.5 px-2">Why unmatched</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thActivator')}</th><th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thPark')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thWhyUnmatched')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -435,7 +446,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<tr key={i}
|
||||
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
|
||||
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
|
||||
title={u.qso_id > 0 ? 'Open this QSO to fix it' : ''}>
|
||||
title={u.qso_id > 0 ? t('qslm.openToFix') : ''}>
|
||||
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
|
||||
<td className="py-1 px-2 font-mono">{u.date}</td>
|
||||
<td className="py-1 px-2">{u.band}</td>
|
||||
@@ -455,27 +466,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
) : showLog ? (
|
||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||
{logLines.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
|
||||
) : logLines.map((l, i) => (
|
||||
<div key={i} className={cn(
|
||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
||||
: 'text-foreground/90')}>{l}</div>
|
||||
))}
|
||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working…</div>}
|
||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
|
||||
</div>
|
||||
) : viewMode === 'confirmations' ? (
|
||||
shownConfs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
|
||||
{confirmations.length === 0 ? t('qslm.noNewConf') : t('qslm.noConfMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -487,9 +498,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<td className="py-1 px-2">{c.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">{t('qslm.newDxcc')}</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">{t('qslm.newBand')}</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">{t('qslm.newSlot')}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -498,7 +509,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.uploadEmpty')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
@@ -515,44 +526,44 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
{service === 'paper' && (
|
||||
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL received</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
|
||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL sent</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslSent')}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select value={qslSent} onValueChange={setQslSent}>
|
||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
|
||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
|
||||
<Select value={qslVia} onValueChange={setQslVia}>
|
||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
|
||||
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder="e.g. paid 3€" />
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
||||
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
|
||||
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder="comment" />
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.comment')}</label>
|
||||
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
||||
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
||||
Apply to {paperSel.size} selected
|
||||
{t('qslm.applyToSelected', { n: paperSel.size })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -562,18 +573,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
||||
title={t('qslm.downloadTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||
</Button>
|
||||
{/* Date window */}
|
||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title="How far back to download">
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="last">Since last download</SelectItem>
|
||||
<SelectItem value="date">Since date…</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="last">{t('qslm.sinceLast')}</SelectItem>
|
||||
<SelectItem value="date">{t('qslm.sinceDate')}</SelectItem>
|
||||
<SelectItem value="all">{t('qslm.sinceAll')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sinceMode === 'date' && (
|
||||
@@ -582,16 +593,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
value={sinceDate}
|
||||
onChange={(e) => setSinceDate(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
title={service === 'qrz' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
|
||||
title={service === 'qrz' ? t('qslm.sinceDateTitleQrz') : t('qslm.sinceDateTitleLotw')}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.addNotFoundTitle')}>
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
Add not-found
|
||||
{t('qslm.addNotFound')}
|
||||
</label>
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
|
||||
@@ -20,12 +21,12 @@ type Props = {
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||
{ service: 'clublog', label: 'Send to Club Log' },
|
||||
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
|
||||
{ service: 'eqsl', label: 'Send to eQSL.cc' },
|
||||
{ service: 'lotw', label: 'Send to LoTW' },
|
||||
const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
{ service: 'qrz', name: 'QRZ.com' },
|
||||
{ service: 'clublog', name: 'Club Log' },
|
||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||
{ service: 'lotw', name: 'LoTW' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
@@ -35,6 +36,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -60,21 +62,21 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{n} QSO{n > 1 ? 's' : ''} selected
|
||||
{t('qctx.selected', { n })}
|
||||
</div>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||
>
|
||||
<Globe2 className="size-4 text-primary" />
|
||||
<span>Fix country & zones from cty.dat</span>
|
||||
<span>{t('qctx.fixCountry')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||
>
|
||||
<RefreshCw className="size-4 text-sky-600" />
|
||||
<span>Update from QRZ.com</span>
|
||||
<span>{t('qctx.updateQrz')}</span>
|
||||
</button>
|
||||
{onUpdateFromClublog && (
|
||||
<button
|
||||
@@ -82,7 +84,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||
>
|
||||
<BadgeCheck className="size-4 text-violet-600" />
|
||||
<span>Update from ClubLog (exceptions)</span>
|
||||
<span>{t('qctx.updateClublog')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -95,7 +97,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||
>
|
||||
<Mail className="size-4 text-amber-600" />
|
||||
<span>Send OpsLog QSL by e-mail</span>
|
||||
<span>{t('qctx.sendQslEmail')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onSendRecording && (
|
||||
@@ -104,7 +106,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
||||
>
|
||||
<Mail className="size-4 text-rose-600" />
|
||||
<span>Send recording by e-mail</span>
|
||||
<span>{t('qctx.sendRecording')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -118,7 +120,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||
>
|
||||
<PencilLine className="size-4 text-indigo-600" />
|
||||
<span>Bulk edit field… ({n})</span>
|
||||
<span>{t('qctx.bulkEdit', { n })}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -132,7 +134,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-sky-600" />
|
||||
<span>Export selected to ADIF ({n})</span>
|
||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportFiltered && (
|
||||
@@ -141,7 +143,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onExportFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-violet-600" />
|
||||
<span>Export filtered view to ADIF (no limit)</span>
|
||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloSelected && (
|
||||
@@ -150,7 +152,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-amber-600" />
|
||||
<span>Export selected to Cabrillo ({n})</span>
|
||||
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloFiltered && (
|
||||
@@ -159,7 +161,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-amber-700" />
|
||||
<span>Export filtered view to Cabrillo (no limit)</span>
|
||||
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -168,14 +170,14 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
{onSendTo && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{UPLOAD_TARGETS.map((t) => (
|
||||
{UPLOAD_TARGETS.map((target) => (
|
||||
<button
|
||||
key={t.service}
|
||||
key={target.service}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendTo(t.service, menu.ids); onClose(); }}
|
||||
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
||||
>
|
||||
<Upload className="size-4 text-emerald-600" />
|
||||
<span>{t.label}</span>
|
||||
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
@@ -189,7 +191,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span>Delete {n} QSO{n > 1 ? 's' : ''}…</span>
|
||||
<span>{t('qctx.delete', { n })}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { QSOForm } from '@/types';
|
||||
|
||||
type QSO = QSOForm;
|
||||
@@ -37,12 +38,13 @@ function pfxOf(call: string): string {
|
||||
|
||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
{ value: '_', label: '—' },
|
||||
{ value: 'Y', label: 'Yes' },
|
||||
{ value: 'N', label: 'No' },
|
||||
{ value: 'R', label: 'Requested' },
|
||||
{ value: 'I', label: 'Ignore' },
|
||||
{ value: '_', label: 'qedit.qslDash' },
|
||||
{ value: 'Y', label: 'qedit.qslYes' },
|
||||
{ value: 'N', label: 'qedit.qslNo' },
|
||||
{ value: 'R', label: 'qedit.qslRequested' },
|
||||
{ value: 'I', label: 'qedit.qslIgnore' },
|
||||
];
|
||||
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
|
||||
@@ -63,16 +65,22 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||
];
|
||||
// i18n label keys for confirmation channels whose label has translatable words
|
||||
// (brand names like LoTW/eQSL stay as their literal label in CONFIRMATIONS).
|
||||
const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
QSL: 'qedit.confQslPaper',
|
||||
};
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
const v = (value || '').toUpperCase();
|
||||
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
||||
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
||||
if (v === '') {
|
||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||
}
|
||||
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
|
||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
||||
: v === 'R' ? 'bg-orange-400 text-white'
|
||||
: v === 'I' ? 'bg-stone-400 text-white'
|
||||
@@ -128,17 +136,19 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
||||
}
|
||||
|
||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||||
// imported/legacy value is never silently dropped from the dropdown.
|
||||
@@ -242,7 +252,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||
async function fetchLookup() {
|
||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||
if (!call) { setLocalErr('Callsign required'); return; }
|
||||
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
@@ -266,14 +276,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
lon: r.lon || d.lon,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
||||
setLocalErr(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
|
||||
} finally {
|
||||
setLooking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
||||
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||
setSaving(true);
|
||||
setLocalErr('');
|
||||
const out: any = {
|
||||
@@ -339,23 +349,23 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="flex-row items-baseline gap-2">
|
||||
<DialogTitle>Edit QSO</DialogTitle>
|
||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t('qedit.editFieldsFor', { id: draft.id })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||
<TabsList className="px-3 overflow-x-auto">
|
||||
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
||||
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
||||
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
||||
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
||||
<TabsTrigger value="qsoinfo">{t('qedit.tabQsoInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
|
||||
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
|
||||
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
|
||||
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
|
||||
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
|
||||
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
|
||||
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
|
||||
<TabsTrigger value="extras">
|
||||
ADIF fields
|
||||
{t('qedit.tabAdifFields')}
|
||||
{extrasCount > 0 && (
|
||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||
)}
|
||||
@@ -373,7 +383,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Top: Callsign + RST + Fetch */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label>Callsign</Label>
|
||||
<Label>{t('qedit.callsign')}</Label>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
@@ -382,39 +392,39 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="flex flex-col w-20"><Label>R</Label>
|
||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* ── Left column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Band</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">RX Band</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.rxBand')}</Label>
|
||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Mode</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.mode')}</Label>
|
||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Country</Label>
|
||||
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
||||
<Label className="w-20 shrink-0">{t('qedit.country')}</Label>
|
||||
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.country')}
|
||||
onChange={onCountryChange} className="flex-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -422,16 +432,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Label>CQ</Label>
|
||||
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
|
||||
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title={t('qedit.dxccTitle')} />
|
||||
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">TX Freq</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">RX Freq</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
</div>
|
||||
@@ -439,7 +449,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* ── Right column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.qsoStart')}</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||
<div>
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||
@@ -448,16 +458,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// Prefill an empty end with the start time so the user
|
||||
// only tweaks the minutes instead of typing a full date.
|
||||
if (on && !dateOff) setDateOff(dateOn);
|
||||
}} /> QSO End (UTC)
|
||||
}} /> {t('qedit.qsoEnd')}
|
||||
</Label>
|
||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||
</div>
|
||||
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -466,10 +476,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* Left column */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.county')}</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||
<div>
|
||||
<Label>Continent</Label>
|
||||
<Label>{t('qedit.continent')}</Label>
|
||||
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -479,17 +489,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
</div>
|
||||
{/* Right column */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.email')}</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
</div>
|
||||
<div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
<div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -503,13 +513,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* Right: computed awards (read-only) derived from this QSO */}
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<span className="text-xs font-semibold">Computed (automatic)</span>
|
||||
<span className="text-xs font-semibold">{t('qedit.computedAuto')}</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
||||
{t('qedit.computedHint')}
|
||||
</p>
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
||||
{computedRefs.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('qedit.noneYet')}</div>
|
||||
) : (
|
||||
computedRefs.map((r) => (
|
||||
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||
@@ -533,27 +543,27 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
<div className="flex-1 max-w-sm space-y-3">
|
||||
<div>
|
||||
<Label>Manage Confirmation</Label>
|
||||
<Label>{t('qedit.manageConf')}</Label>
|
||||
<Select value={confSel} onValueChange={setConfSel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>Received</Label>
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label>
|
||||
{def.rcvd
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
</div>
|
||||
<div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
{def.via && (
|
||||
<div className="col-span-2"><Label>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
|
||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a channel, edit it — the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -562,15 +572,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
||||
<thead>
|
||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-semibold">Type</th>
|
||||
<th className="font-semibold">Sent</th>
|
||||
<th className="font-semibold">Received</th>
|
||||
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||
<th className="font-semibold">{t('qedit.sent')}</th>
|
||||
<th className="font-semibold">{t('qedit.received')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CONFIRMATIONS.map((c) => (
|
||||
<tr key={c.key} className="text-xs">
|
||||
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
||||
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||
</tr>
|
||||
@@ -585,88 +595,88 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
<TabsContent value="contest" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder="rcvd exchange" onChange={(e) => setExchange('srx', e.target.value)} /></F>
|
||||
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder="sent exchange" onChange={(e) => setExchange('stx', e.target.value)} /></F>
|
||||
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||
<F label={t('qedit.contestId')} span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder={t('qedit.rcvdExchange')} onChange={(e) => setExchange('srx', e.target.value)} /></F>
|
||||
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder={t('qedit.sentExchange')} onChange={(e) => setExchange('stx', e.target.value)} /></F>
|
||||
<F label={t('qedit.check')}><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||
<F label={t('qedit.precedence')}><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||
<F label={t('qedit.arrlSection')}><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sat" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Propagation mode">
|
||||
<F label={t('qedit.propMode')}>
|
||||
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</F>
|
||||
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||
<F label={t('qedit.satName')}><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||
<F label={t('qedit.satMode')}><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||
<F label={t('qedit.antAz')}><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.antEl')}><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.antPath')}><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mystation" className="mt-0 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('qedit.myStationHint')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Station callsign" span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="Country" onChange={(v) => set('my_country', v)} /></F>
|
||||
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
||||
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.cqZone')}><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.ituZone')}><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
|
||||
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||
<F label={t('qedit.sotaRef')}><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||
<F label={t('qedit.potaRef')}><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.specialActivity')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
|
||||
<F label="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||
<F label="WWFF ref" span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||
<F label={t('qedit.sigInfo')} span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||
<F label={t('qedit.wwffRef')} span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.region')}><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power & propagation */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power & space weather</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.kIndex')}><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity & clubs */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Identity & clubs</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.identityClubs')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||
<F label={t('qedit.contactedOp')} span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.formerCall')} span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.class')}><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
|
||||
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
|
||||
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
||||
@@ -676,28 +686,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* Flags & credits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags & credits</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.flagsCredits')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||
<F label={t('qedit.qsoComplete')}><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||
<F label={t('qedit.qsoRandom')}><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||
<F label={t('qedit.silentKey')}><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
|
||||
<F label="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||
<F label={t('qedit.creditGranted')} span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||
<F label={t('qedit.creditSubmitted')} span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My station extras */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.myStationAdif')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.myName')} span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||
<F label={t('qedit.myWwffRef')} span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.myArrlSect')} span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||
<F label={t('qedit.mySig')}><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||
<F label={t('qedit.mySigInfo')} span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||
<F label={t('qedit.myDarcDok')}><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||
<F label={t('qedit.myVuccGrids')} span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -710,11 +720,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
<DialogFooter className="!flex-row gap-2">
|
||||
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
<Trash2 className="size-3.5" /> {t('qedit.delete')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
@@ -102,127 +103,131 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
||||
|
||||
// Shared so the Worked-before grid (which now also shows full QSO records)
|
||||
// can offer the exact same column choices without duplicating the catalog.
|
||||
export const COL_CATALOG: ColEntry[] = [
|
||||
// A factory taking the i18n `t()` so headerName/label strings are localized;
|
||||
// hooks can't run at module level, so the component calls this with its own t.
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
// ── QSO basics ──
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.band'), colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.band_rx'), colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.mode'), colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.submode'), colId: 'submode', headerName: t('rqg.c.submode'), field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.freq_hz'), colId: 'freq_hz', headerName: t('rqg.h.freq_hz'), field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.freq_rx_hz'), colId: 'freq_rx_hz', headerName: t('rqg.h.freq_rx_hz'), field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: t('rqg.c.rst_sent'), colId: 'rst_sent', headerName: t('rqg.c.rst_sent'), field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.rst_rcvd'), colId: 'rst_rcvd', headerName: t('rqg.c.rst_rcvd'), field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.tx_pwr'), colId: 'tx_pwr', headerName: t('rqg.c.tx_pwr'), field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
|
||||
// ── Contacted station ──
|
||||
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
|
||||
{ group: 'Contacted', label: t('rqg.c.name'), colId: 'name', headerName: t('rqg.c.name'), field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.qth'), colId: 'qth', headerName: t('rqg.c.qth'), field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.address'), colId: 'address', headerName: t('rqg.c.address'), field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: t('rqg.c.country'), colId: 'country', headerName: t('rqg.c.country'), field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.state'), colId: 'state', headerName: t('rqg.c.state'), field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: t('rqg.c.cnty'), colId: 'cnty', headerName: t('rqg.c.cnty'), field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: t('rqg.c.cont'),colId: 'cont', headerName: t('rqg.h.cont'), field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: t('rqg.c.grid'), colId: 'grid', headerName: t('rqg.c.grid'), field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.gridsquare_ext'), colId: 'gridsquare_ext', headerName: t('rqg.h.gridsquare_ext'), field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.vucc_grids'),colId: 'vucc_grids', headerName: t('rqg.h.vucc_grids'), field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.dxcc'), colId: 'dxcc', headerName: t('rqg.c.dxcc'), field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.cqz'), colId: 'cqz', headerName: t('rqg.c.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.ituz'), colId: 'ituz', headerName: t('rqg.c.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.iota'), colId: 'iota', headerName: t('rqg.c.iota'), field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.sota_ref'), colId: 'sota_ref', headerName: t('rqg.h.sota_ref'), field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.pota_ref'), colId: 'pota_ref', headerName: t('rqg.h.pota_ref'), field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: 'ClubLog sent', colId: 'clublog_qso_upload_status', headerName: 'ClubLog sent', field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'ClubLog sent date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog S date', field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
|
||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: t('rqg.c.srx'), colId: 'srx', headerName: t('rqg.c.srx'), field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: t('rqg.c.stx'), colId: 'stx', headerName: t('rqg.c.stx'), field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: t('rqg.c.srx_string'), colId: 'srx_string', headerName: t('rqg.h.srx_string'), field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: t('rqg.c.stx_string'), colId: 'stx_string', headerName: t('rqg.h.stx_string'), field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: t('rqg.c.check'), colId: 'check', headerName: t('rqg.c.check'), field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: t('rqg.c.precedence'), colId: 'precedence', headerName: t('rqg.c.precedence'), field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: t('rqg.c.arrl_sect'),colId: 'arrl_sect', headerName: t('rqg.h.arrl_sect'), field: 'arrl_sect' as any, width: 90 },
|
||||
|
||||
// ── Propagation / antenna ──
|
||||
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
||||
{ group: 'Propagation', label: t('rqg.c.prop_mode'), colId: 'prop_mode', headerName: t('rqg.h.prop_mode'), field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: t('rqg.c.sat_name'), colId: 'sat_name', headerName: t('rqg.h.sat_name'), field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: t('rqg.c.sat_mode'), colId: 'sat_mode', headerName: t('rqg.c.sat_mode'), field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_az'), colId: 'ant_az', headerName: t('rqg.h.ant_az'), field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_el'), colId: 'ant_el', headerName: t('rqg.h.ant_el'), field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_path'), colId: 'ant_path', headerName: t('rqg.h.ant_path'), field: 'ant_path' as any, width: 70 },
|
||||
|
||||
// ── My station (operator side) ──
|
||||
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.station_callsign'), colId: 'station_callsign', headerName: t('rqg.h.station_callsign'), field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: t('rqg.c.operator'), colId: 'operator', headerName: t('rqg.c.operator'),field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_grid'), colId: 'my_grid', headerName: t('rqg.c.my_grid'), field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_country'), colId: 'my_country', headerName: t('rqg.h.my_country'), field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_state'), colId: 'my_state', headerName: t('rqg.c.my_state'),field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: t('rqg.c.my_cnty'), colId: 'my_cnty', headerName: t('rqg.h.my_cnty'), field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: t('rqg.c.my_iota'), colId: 'my_iota', headerName: t('rqg.c.my_iota'), field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_sota'), colId: 'my_sota_ref', headerName: t('rqg.c.my_sota'), field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_pota'), colId: 'my_pota_ref', headerName: t('rqg.c.my_pota'), field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_dxcc'), colId: 'my_dxcc', headerName: t('rqg.h.my_dxcc'),field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_cq_zone'), colId: 'my_cq_zone', headerName: t('rqg.h.my_cq_zone'), field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_itu_zone'), colId: 'my_itu_zone', headerName: t('rqg.h.my_itu_zone'), field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_lat'), colId: 'my_lat', headerName: t('rqg.c.my_lat'), field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_lon'), colId: 'my_lon', headerName: t('rqg.c.my_lon'), field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_street'), colId: 'my_street', headerName: t('rqg.h.my_street'), field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: t('rqg.c.my_city'), colId: 'my_city', headerName: t('rqg.h.my_city'), field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||
|
||||
// ── Misc ──
|
||||
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: t('rqg.c.notes'), colId: 'notes', headerName: t('rqg.c.notes'), field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: t('rqg.c.created'), colId: 'created_at', headerName: t('rqg.h.created'), field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: t('rqg.c.updated'), colId: 'updated_at', headerName: t('rqg.h.updated'), field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
];
|
||||
|
||||
export const GROUP_ORDER = [
|
||||
@@ -230,11 +235,25 @@ export const GROUP_ORDER = [
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
// Localized display label for a column group (identifier stays English so it
|
||||
// keeps working as a filter key).
|
||||
const GRP_KEYS: Record<string, string> = {
|
||||
QSO: 'rqg.grpQso', Contacted: 'rqg.grpContacted', QSL: 'rqg.grpQsl',
|
||||
LoTW: 'rqg.grpLotw', eQSL: 'rqg.grpEqsl', Uploads: 'rqg.grpUploads',
|
||||
Contest: 'rqg.grpContest', Propagation: 'rqg.grpProp',
|
||||
'My station': 'rqg.grpMyStation', Misc: 'rqg.grpMisc',
|
||||
};
|
||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
// Right-click: if the clicked row isn't already part of the selection,
|
||||
// select just it; then open the bulk-action menu on the whole selection.
|
||||
function onCellContextMenu(e: any) {
|
||||
@@ -271,13 +290,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
cellClass: 'text-[11px]',
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols]);
|
||||
}, [awardCols, COL_CATALOG, t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
@@ -373,11 +392,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
title={t('rqg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('rqg.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
@@ -426,10 +445,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Columns</DialogTitle>
|
||||
<DialogTitle>{t('rqg.columns')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Recent QSOs table.
|
||||
Your selection is saved.
|
||||
{t('rqg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
@@ -439,10 +457,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -462,10 +480,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
{awardCols && awardCols.length > 0 && (
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -481,8 +499,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('rqg.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('rqg.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface RecentSpotQSO {
|
||||
callsign: string;
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||
// contact into the form.
|
||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [call, setCall] = useState('');
|
||||
const [freqKHz, setFreqKHz] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -56,8 +58,8 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
async function send() {
|
||||
const c = call.trim().toUpperCase();
|
||||
const f = parseFloat(freqKHz);
|
||||
if (!c) { setError('Callsign required'); return; }
|
||||
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
|
||||
if (!c) { setError(t('spm.callRequired')); return; }
|
||||
if (!f || f <= 0) { setError(t('spm.freqRequired')); return; }
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -84,25 +86,25 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Satellite className="size-4 text-primary" /> Send DX Spot
|
||||
<Satellite className="size-4 text-primary" /> {t('spm.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col flex-1">
|
||||
<Label className="mb-1">Callsign</Label>
|
||||
<Label className="mb-1">{t('spm.callsign')}</Label>
|
||||
<Input
|
||||
ref={callRef}
|
||||
className="font-mono uppercase font-bold"
|
||||
value={call}
|
||||
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="DX call"
|
||||
placeholder={t('spm.callPh')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1">Frequency (kHz)</Label>
|
||||
<Label className="mb-1">{t('spm.frequency')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={freqKHz}
|
||||
@@ -113,18 +115,18 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">Message</Label>
|
||||
<Label className="mb-1">{t('spm.message')}</Label>
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="e.g. CW · TNX QSO"
|
||||
placeholder={t('spm.messagePh')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1 block">Latest QSOs</Label>
|
||||
<Label className="mb-1 block">{t('spm.latestQsos')}</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
|
||||
{recent.map((q, i) => (
|
||||
<button
|
||||
@@ -147,12 +149,12 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
|
||||
<DialogFooter>
|
||||
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
||||
{ok ? 'Spot sent ✓' : targetName ? `→ ${targetName}` : 'Master cluster'}
|
||||
{ok ? t('spm.spotSent') : targetName ? `→ ${targetName}` : t('spm.masterCluster')}
|
||||
</span>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('spm.cancel')}</Button>
|
||||
<Button onClick={send} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
||||
{busy ? 'Sending…' : 'Send spot'}
|
||||
{busy ? t('spm.sending') : t('spm.sendSpot')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Local mirror of the Go struct — we duplicate the type rather than depend
|
||||
// on the generated Wails model because the inline `as any` casts are
|
||||
@@ -44,36 +45,36 @@ const SERVICE_TYPES: Array<{
|
||||
{
|
||||
id: 'wsjt',
|
||||
direction: 'inbound',
|
||||
label: 'WSJT-X / JTDX / MSHV',
|
||||
hint: 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.',
|
||||
label: 'udpp.svcWsjtLabel',
|
||||
hint: 'udpp.svcWsjtHint',
|
||||
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'adif',
|
||||
direction: 'inbound',
|
||||
label: 'ADIF message (JTAlert, GridTracker)',
|
||||
hint: 'Receives a single ADIF record per packet and logs it.',
|
||||
label: 'udpp.svcAdifLabel',
|
||||
hint: 'udpp.svcAdifHint',
|
||||
defaults: { port: 2333, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'n1mm',
|
||||
direction: 'inbound',
|
||||
label: 'N1MM Logger+ (contest XML)',
|
||||
hint: 'Receives contest QSOs as XML messages.',
|
||||
label: 'udpp.svcN1mmLabel',
|
||||
hint: 'udpp.svcN1mmHint',
|
||||
defaults: { port: 12060, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'remote_call',
|
||||
direction: 'inbound',
|
||||
label: 'Remote callsign (DXHunter, custom)',
|
||||
hint: 'A short text packet containing just a callsign — fills the entry field.',
|
||||
label: 'udpp.svcRemoteLabel',
|
||||
hint: 'udpp.svcRemoteHint',
|
||||
defaults: { port: 12090, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'db_updated',
|
||||
direction: 'outbound',
|
||||
label: 'DB updated → notify other apps',
|
||||
hint: 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
|
||||
label: 'udpp.svcDbLabel',
|
||||
hint: 'udpp.svcDbHint',
|
||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
];
|
||||
@@ -81,6 +82,7 @@ const SERVICE_TYPES: Array<{
|
||||
type Props = { onError: (msg: string) => void };
|
||||
|
||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<UDPConfig | null>(null);
|
||||
@@ -123,7 +125,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this UDP connection?')) return;
|
||||
if (!confirm(t('udpp.deleteConfirm'))) return;
|
||||
try {
|
||||
await DeleteUDPIntegration(id);
|
||||
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||
@@ -143,7 +145,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-xs text-muted-foreground italic">Loading…</div>;
|
||||
if (loading) return <div className="text-xs text-muted-foreground italic">{t('udpp.loading')}</div>;
|
||||
|
||||
const inbound = items.filter((i) => i.direction === 'inbound');
|
||||
const outbound = items.filter((i) => i.direction === 'outbound');
|
||||
@@ -151,15 +153,11 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
UDP connections let OpsLog talk to other ham radio software. Inbound
|
||||
connections receive QSOs or callsigns and update the logbook live;
|
||||
outbound connections notify other apps when you log a QSO locally.
|
||||
Enable multicast to share a port with another listener without
|
||||
conflict — required for the typical WSJT-X 2237 setup.
|
||||
{t('udpp.intro')}
|
||||
</div>
|
||||
|
||||
<Section
|
||||
title="Inbound — OpsLog listens"
|
||||
title={t('udpp.inboundTitle')}
|
||||
icon={<ArrowDownToLine className="size-4" />}
|
||||
items={inbound}
|
||||
onAdd={() => addNew('inbound')}
|
||||
@@ -168,7 +166,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
onToggle={toggleEnabled}
|
||||
/>
|
||||
<Section
|
||||
title="Outbound — OpsLog sends"
|
||||
title={t('udpp.outboundTitle')}
|
||||
icon={<ArrowUpFromLine className="size-4" />}
|
||||
items={outbound}
|
||||
onAdd={() => addNew('outbound')}
|
||||
@@ -179,10 +177,10 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||
<Button size="sm" variant="outline" onClick={reloadServers}>
|
||||
<RefreshCcw className="size-3.5" /> Reload all
|
||||
<RefreshCcw className="size-3.5" /> {t('udpp.reloadAll')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Restarts every enabled listener after a manual change.
|
||||
{t('udpp.reloadHint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -210,6 +208,7 @@ function Section({
|
||||
onDelete: (id: number) => void;
|
||||
onToggle: (c: UDPConfig) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
@@ -217,11 +216,11 @@ function Section({
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onAdd}>
|
||||
<Plus className="size-3" /> Add
|
||||
<Plus className="size-3" /> {t('udpp.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground italic">No connection.</div>
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground italic">{t('udpp.noConnection')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{items.map((c) => {
|
||||
@@ -231,9 +230,9 @@ function Section({
|
||||
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm truncate">{c.name || '(unnamed)'}</span>
|
||||
<span className="font-semibold text-sm truncate">{c.name || t('udpp.unnamed')}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{svc?.label ?? c.service_type}
|
||||
{svc ? t(svc.label) : c.service_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
@@ -269,6 +268,7 @@ function EditDialog({
|
||||
onCancel: () => void;
|
||||
onSave: (c: UDPConfig) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = useState<UDPConfig>(cfg);
|
||||
// Service-type list filtered to this connection's direction.
|
||||
const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction);
|
||||
@@ -292,31 +292,34 @@ function EditDialog({
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{cfg.id === 0 ? 'New' : 'Edit'} {draft.direction} connection
|
||||
{t('udpp.dialogTitle', {
|
||||
action: cfg.id === 0 ? t('udpp.new') : t('udpp.edit'),
|
||||
direction: draft.direction === 'inbound' ? t('udpp.directionInbound') : t('udpp.directionOutbound'),
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentService?.hint}
|
||||
{currentService ? t(currentService.hint) : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Name</Label>
|
||||
<Label>{t('udpp.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={draft.direction === 'inbound' ? 'WSJT-X log' : 'Cloudlog notify'}
|
||||
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Service type</Label>
|
||||
<Label>{t('udpp.serviceType')}</Label>
|
||||
<Select value={draft.service_type} onValueChange={(v) => applyPreset(v as UDPConfig['service_type'])}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
<SelectItem key={s.id} value={s.id}>{t(s.label)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -324,7 +327,7 @@ function EditDialog({
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Label>{t('udpp.port')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1} max={65535}
|
||||
@@ -341,13 +344,13 @@ function EditDialog({
|
||||
checked={draft.multicast}
|
||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
|
||||
/>
|
||||
<span>Multicast</span>
|
||||
<span>{t('udpp.multicast')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.multicast && (
|
||||
<div className="space-y-1">
|
||||
<Label>Multicast group</Label>
|
||||
<Label>{t('udpp.multicastGroup')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="224.0.0.1"
|
||||
@@ -355,14 +358,14 @@ function EditDialog({
|
||||
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Use the same group address as the sending app. WSJT-X default is 224.0.0.1.
|
||||
{t('udpp.multicastHint')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.direction === 'outbound' && (
|
||||
<div className="space-y-1">
|
||||
<Label>Destination IP</Label>
|
||||
<Label>{t('udpp.destinationIp')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="127.0.0.1"
|
||||
@@ -377,17 +380,17 @@ function EditDialog({
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
<span>{t('udpp.enabled')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>{t('udpp.cancel')}</Button>
|
||||
<Button
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={!draft.name.trim() || !draft.port}
|
||||
>
|
||||
Save
|
||||
{t('udpp.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface WKMacro { label: string; text: string }
|
||||
export interface WKStatus {
|
||||
@@ -54,6 +55,7 @@ export function WinkeyerPanel({
|
||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [speed, setSpeed] = useState(wpm);
|
||||
// Step the speed (compact +/- control replaces the old slider).
|
||||
@@ -96,30 +98,30 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? 'Sending…' : `Connected (v${status.version})`) : 'Disconnected'} />
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{!connected ? (
|
||||
<>
|
||||
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder="COM port" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ports.length === 0 && <SelectItem value="_" disabled>No ports</SelectItem>}
|
||||
{ports.length === 0 && <SelectItem value="_" disabled>{t('wkp.noPorts')}</SelectItem>}
|
||||
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Refresh ports" onClick={onRefreshPorts}>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.refreshPorts')} onClick={onRefreshPorts}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
|
||||
<Plug className="size-3.5" /> Connect
|
||||
<Plug className="size-3.5" /> {t('wkp.connect')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
|
||||
<Power className="size-3.5" /> Disconnect
|
||||
<Power className="size-3.5" /> {t('wkp.disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Hide / disable WinKeyer" onClick={onClose}>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.hide')} onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -136,13 +138,13 @@ export function WinkeyerPanel({
|
||||
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||
</div>
|
||||
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
|
||||
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="CW speed (WPM)">
|
||||
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.cwSpeed')}>
|
||||
<span className="font-mono text-sm font-bold tabular-nums">{speed}</span>
|
||||
<span className="text-[9px] text-muted-foreground">wpm</span>
|
||||
<div className="flex flex-col -my-0.5">
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title="Faster"
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title={t('wkp.faster')}
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronUp className="size-3.5" /></button>
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title="Slower"
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title={t('wkp.slower')}
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,28 +154,28 @@ export function WinkeyerPanel({
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
CW text
|
||||
{t('wkp.cwText')}
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title="Key each character live as you type (backspace removes un-sent chars)">
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||
send on type
|
||||
{t('wkp.sendOnType')}
|
||||
</label>
|
||||
</Label>
|
||||
<Input
|
||||
value={cwText}
|
||||
onChange={(e) => onCwChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||
placeholder={sendOnType ? 'Type — sent live…' : 'Type and press Enter to send…'}
|
||||
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
disabled={!connected}
|
||||
className="font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||
<Send className="size-3.5" /> {sendOnType ? 'Clear' : 'Send'}
|
||||
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title="Abort (clear keyer buffer)">
|
||||
<Square className="size-3.5" /> Stop
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -181,19 +183,19 @@ export function WinkeyerPanel({
|
||||
someone answers. The seconds box is the gap AFTER the message. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||
title="Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.">
|
||||
title={t('wkp.autoCallHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||
Auto-call
|
||||
{t('wkp.autoCall')}
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">gap</span>
|
||||
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
|
||||
<span className="text-[11px] text-muted-foreground">{t('wkp.gap')}</span>
|
||||
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.gapHint')}>
|
||||
<input type="number" min={0} max={120}
|
||||
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||
</div>
|
||||
{autoCall && <span className="text-[10px] text-amber-600/80">click a CQ macro to loop it</span>}
|
||||
{autoCall && <span className="text-[10px] text-amber-600/80">{t('wkp.loopHint')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
@@ -211,7 +213,7 @@ export function WinkeyerPanel({
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-primary font-semibold shrink-0">F{i + 1}</span>
|
||||
<span className="text-xs font-medium truncate">{m.label || `Macro ${i + 1}`}</span>
|
||||
<span className="text-xs font-medium truncate">{m.label || t('wkp.macroN', { n: i + 1 })}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { COL_CATALOG, GROUP_ORDER } from './RecentQSOsGrid';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
@@ -69,10 +70,14 @@ function fmtDate(s: any): string {
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
@@ -109,13 +114,13 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||
headerTooltip: t('wbg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
cellClass: 'text-[11px]',
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols]);
|
||||
}, [awardCols, COL_CATALOG, t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
@@ -185,14 +190,14 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
if (!hasCall) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign in the entry strip to see prior contacts.
|
||||
{t('wbg.typeCall')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (busy && count === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
{t('wbg.checking')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,8 +205,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-8 text-primary fill-current" />
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">{t('wbg.new')}</div>
|
||||
<div>{t('wbg.noPriorPre')}<span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>{t('wbg.noPriorPost')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -210,29 +215,29 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.workedBefore')}</span>
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
|
||||
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
{t('wbg.first')} <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
{t('wbg.last')} <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
{wb?.dxcc_name && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{t('wbg.dxcc')} <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
|
||||
<span> · {wb.dxcc_count} entity QSOs</span>
|
||||
<span> · {t('wbg.entityQsos', { n: wb.dxcc_count })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
title={t('wbg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('wbg.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('wbg.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -275,16 +280,16 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||||
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||||
{t('wbg.olderQsos', { n: count - entries.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Worked-before columns</DialogTitle>
|
||||
<DialogTitle>{t('wbg.pickerTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Worked-before table.
|
||||
{t('wbg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
@@ -294,10 +299,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('wbg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('wbg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -317,10 +322,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
{awardCols && awardCols.length > 0 && (
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.grpAwards')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('wbg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('wbg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -336,8 +341,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('wbg.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('wbg.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -21,6 +21,8 @@ const en: Dict = {
|
||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog',
|
||||
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
|
||||
// Tabs
|
||||
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
|
||||
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
|
||||
@@ -35,6 +37,168 @@ const en: Dict = {
|
||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||
// Settings navigation (sidebar groups + section names)
|
||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
||||
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
// General panel
|
||||
'gen.hint': 'App behaviour (saved instantly).',
|
||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)',
|
||||
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
|
||||
'email.title': 'E-mail',
|
||||
// Station panel
|
||||
'station.loading': 'Loading profile…',
|
||||
'station.hint': 'Editing the active profile: {name}. Switch profiles in the Profiles section to edit a different one.',
|
||||
'station.stationCall': 'Station callsign', 'station.stationCallHint': "What's transmitted (ADIF STATION_CALLSIGN).",
|
||||
'station.opCall': 'Operator callsign', 'station.opCallHint': "Who's at the radio (ADIF OPERATOR).",
|
||||
'station.ownerCall': 'Owner callsign', 'station.ownerBlank': '(leave blank if same as station)',
|
||||
'station.ownerHint': 'Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).',
|
||||
'station.opName': 'Operator name', 'station.opNameHint': 'Your first name — used as the signature on QSL cards.',
|
||||
'station.autofill': 'Auto-filled from the callsign — editable (stamped as MY_* on each QSO)',
|
||||
'station.myGrid': 'My grid', 'station.myCountry': 'My country', 'station.dxcc': 'DXCC #', 'station.cqz': 'CQ zone', 'station.ituz': 'ITU zone',
|
||||
'station.lat': 'Latitude', 'station.lon': 'Longitude', 'station.state': 'State / pref', 'station.county': 'County',
|
||||
'station.street': 'Street address', 'station.postal': 'Postal code', 'station.city': 'City', 'station.sota': 'SOTA ref', 'station.pota': 'POTA ref',
|
||||
// Confirmations panel
|
||||
'conf.hint': 'Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty.',
|
||||
'conf.sent': 'Sent', 'conf.rcvd': 'Rcvd', 'conf.paperQsl': 'Paper QSL',
|
||||
'conf.note': '"Sent" = the QSO\'s upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log & HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.',
|
||||
'conf.blank': '— leave blank —', 'conf.y': 'Y (yes)', 'conf.n': 'N (no)', 'conf.r': 'R (requested)', 'conf.q': 'Q (queued)', 'conf.i': 'I (ignore)',
|
||||
'udp.hint': 'Listen for QSO logs from WSJT-X / JTDX / MSHV, ADIF messages from JTAlert/GridTracker, or simple callsign packets from external tools. Outbound connections forward every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
|
||||
'operating.hint': "Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on.",
|
||||
'op.intro1': 'Define your rigs (stations) and the antennas connected to each one. For every antenna, tick the bands it covers. ',
|
||||
'op.intro2': ' marks the default antenna for that band — when you change the band in the entry strip, the matching rig + antenna auto-fill the MY_RIG and MY_ANTENNA ADIF fields. Only one antenna can be the default per band; setting one clears the previous default.',
|
||||
'op.addRig': 'Add rig', 'op.noRig': 'No rig configured yet. Click "Add rig" to get started.',
|
||||
'op.rigNamePh': 'Rig name (also stamped as MY_RIG)', 'op.powerW': 'Power (W)',
|
||||
'op.antenna': 'Antenna', 'op.deleteRig': 'Delete rig', 'op.noAntenna': 'No antenna yet — click "Antenna" above to add one.',
|
||||
'op.antNamePh': 'Antenna name (also stamped as MY_ANTENNA)', 'op.default': 'Default', 'op.set': 'Set',
|
||||
'op.defaultOn': 'Default antenna for this band — click to unset', 'op.defaultOff': 'Click to make this antenna the default for this band',
|
||||
'op.collapse': 'Collapse', 'op.expand': 'Expand', 'op.deleteAntConfirm': 'Delete this antenna?', 'op.edit': 'Edit',
|
||||
// Lookup panel
|
||||
'lk.hint': 'Pick a Primary provider and an optional Failsafe (queried only when Primary returns no data). Click Test to verify credentials without saving.',
|
||||
'lk.provider': 'Provider', 'lk.primary': 'Primary', 'lk.failsafe': 'Failsafe', 'lk.user': 'User', 'lk.password': 'Password', 'lk.result': 'Result',
|
||||
'lk.testing': 'Testing…', 'lk.test': 'Test', 'lk.testTitle': "Run a sample lookup against the active profile's callsign to verify credentials",
|
||||
'lk.failsafeNote': 'Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.',
|
||||
'lk.display': 'Display', 'lk.showPics': 'Show QRZ profile pictures',
|
||||
'lk.showPicsHint': 'Display the photo from QRZ.com next to the worked-before matrix. May noticeably slow lookups during busy contest days; turn off if you operate fast.',
|
||||
'lk.cache': 'Cache', 'lk.cacheHint': "Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.",
|
||||
'lk.ttl': 'TTL (days)', 'lk.clearing': 'Clearing…', 'lk.clearCache': 'Clear cache now',
|
||||
// Bands panel
|
||||
'bnd.hint': "Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order.",
|
||||
'bnd.available': 'Available', 'bnd.allSelected': 'All catalog bands selected.', 'bnd.customPh': 'Custom band (e.g. 4m)',
|
||||
'bnd.selected': 'Selected ({n})', 'bnd.none': 'No band selected — pick from the left.',
|
||||
// Modes panel
|
||||
'mds.hint': 'Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you\'ve typed something).',
|
||||
'mds.allSelected': 'All catalog modes selected.', 'mds.customPh': 'Custom mode', 'mds.order': 'Order', 'mds.mode': 'Mode',
|
||||
'mds.rstSnt': 'RST snt', 'mds.rstRcv': 'RST rcv', 'mds.none': 'No mode selected — pick from the left.', 'mds.addBlank': 'Add blank row',
|
||||
'mds.defaultRstTitle': 'Default RST: {sent} / {rcvd}',
|
||||
'mds.rstLists': 'RST report lists', 'mds.rstListsHint': "The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.",
|
||||
'mds.phone': 'Phone (SSB/AM/FM)', 'mds.cw': 'CW / RTTY / PSK', 'mds.digital': 'Digital (FT8/FT4/JT…) — dB',
|
||||
// Cluster panel
|
||||
'clu.hint': 'Connect to one or several DX cluster nodes (telnet). The first enabled server is the master — typed commands and init commands go through it.',
|
||||
'clu.name': 'Name', 'clu.hostPort': 'Host:port', 'clu.status': 'Status', 'clu.actions': 'Actions',
|
||||
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.',
|
||||
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
|
||||
'clu.freeNodes': 'Free public nodes:',
|
||||
// Backup panel
|
||||
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
||||
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
|
||||
'bk.auto': 'Automatic backup when closing OpsLog (max once per day)', 'bk.folder': 'Backup folder', 'bk.folderPh': 'leave empty for default', 'bk.browse': 'Browse…',
|
||||
'bk.effective': 'Effective folder:', 'bk.defaultUse': 'If empty, OpsLog uses the default:', 'bk.rotation': 'Rotation (copies to keep)', 'bk.zip': 'ZIP backup (smaller file)',
|
||||
'bk.lastRun': 'Last run:', 'bk.never': 'never', 'bk.backupNow': 'Back up now', 'bk.backingUp': 'Backing up…', 'bk.writtenTo': 'Backup written to',
|
||||
// Section hints (hardware/software panel headers)
|
||||
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
|
||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).',
|
||||
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
// CAT panel body
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
|
||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
|
||||
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
|
||||
'cat.flexHint': 'Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in real time from the radio (no polling, no second-click mode bug). Use Detect radios or enter the IP. Default digital mode is what OpsLog logs when the slice is in a digital mode (DIGU/DIGL).',
|
||||
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
|
||||
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
|
||||
// External services (repeated labels)
|
||||
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||
// Profiles panel
|
||||
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
|
||||
'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name',
|
||||
'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.",
|
||||
// Database panel
|
||||
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
|
||||
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
|
||||
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
||||
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
||||
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
||||
'db.activeBackend': 'Active backend:', 'db.logbook': 'Logbook:', 'db.config': 'Config:', 'db.mysqlConnected': 'shared MySQL connected ✓', 'db.localSqlite': 'local SQLite',
|
||||
'db.fallback': 'MySQL is enabled but the connection failed at startup — OpsLog is running on the local SQLite database.',
|
||||
'db.mysqlNote': "Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied — import them into the shared log if you want your history there.",
|
||||
// Email panel
|
||||
'em.none': 'None', 'em.smtpAuth': 'SMTP requires authorization', 'em.username': 'Username', 'em.fromAddr': 'From address', 'em.replyTo': 'Reply-To address', 'em.replyToPh': '(optional — where replies go)', 'em.replyToHint': 'Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.',
|
||||
'em.sendTest': 'Send test e-mail', 'em.sendingTest': 'Sending test…', 'em.testSent': 'Test e-mail sent ✓', 'em.testFailed': 'Test failed: ',
|
||||
'em.qslCardEmail': 'OpsLog QSL card e-mail', 'em.qslVarsHint': 'Message sent with the QSL card. Variables:', 'em.subject': 'Subject', 'em.body': 'Body', 'em.autoSend': 'Auto-send OpsLog QSL when a QSO is logged', 'em.autoSendHint': 'Sends automatically only when the contact has an e-mail address and a default QSL template exists.',
|
||||
'settings.title': 'Preferences',
|
||||
'btn.cancel': 'Cancel', 'btn.save': 'Save', 'btn.saveClose': 'Save and close', 'btn.savingLong': 'Saving…',
|
||||
// Component keys (chat / call history / band map / first-run / contest / adif extras)
|
||||
'chatp.chat': 'Chat', 'chatp.online': 'Online', 'chatp.close': 'Close', 'chatp.noMessages': 'No messages yet.', 'chatp.messagePh': 'Message…',
|
||||
'chp.workedBefore': 'Worked before', 'chp.typeCallsign': 'Type a callsign to see prior contacts.', 'chp.checking': 'checking…', 'chp.new': 'NEW',
|
||||
'chp.noPriorQso': 'No prior QSO with this callsign.', 'chp.first': 'First', 'chp.last': 'Last', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Band', 'chp.mode': 'Mode',
|
||||
'chp.lotwRcvd': 'LoTW rcvd', 'chp.bureauRcvd': 'Bureau rcvd', 'chp.olderQsos': '+ {n} older QSOs',
|
||||
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)',
|
||||
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
||||
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
|
||||
'ctp.contestSetup': 'Contest setup', 'ctp.live': 'Live', 'ctp.chooseContest': '— choose a contest —', 'ctp.serial': 'Serial', 'ctp.fixedExchange': 'Fixed exchange', 'ctp.nextSerial': 'Next serial', 'ctp.yourExchangePh': 'your exchange', 'ctp.startUtc': 'Start (UTC)', 'ctp.endUtc': 'End (UTC)', 'ctp.windowHint': 'empty end = open · leave blank to count all',
|
||||
'ctp.stopContest': 'Stop contest', 'ctp.startContest': 'Start contest', 'ctp.activeHint': 'The entry form shows Snt/Rcv and a DUPE badge; QSOs are stamped with this contest.', 'ctp.inactiveHint': 'Pick a contest, set the window, then Start.', 'ctp.scoreboard': 'Scoreboard', 'ctp.estimate': 'estimate', 'ctp.qsos': 'QSOs', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Last 60 min', 'ctp.band': 'Band',
|
||||
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
||||
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
||||
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
||||
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
|
||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter',
|
||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
||||
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
||||
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'DB updated → notify other apps', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
||||
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
||||
// Awards (ref picker / ref selector / awards panel / award editor)
|
||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
||||
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
|
||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
|
||||
// Grids column headers (recent / worked-before / cluster)
|
||||
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date off', 'rqg.c.callsign': 'Callsign', 'rqg.c.band': 'Band', 'rqg.c.band_rx': 'Band RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Submode', 'rqg.c.freq_hz': 'Freq (TX)', 'rqg.h.freq_hz': 'Freq', 'rqg.c.freq_rx_hz': 'Freq (RX)', 'rqg.h.freq_rx_hz': 'Freq RX', 'rqg.c.rst_sent': 'RST sent', 'rqg.c.rst_rcvd': 'RST rcvd', 'rqg.c.tx_pwr': 'TX Power', 'rqg.c.name': 'Name', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Address', 'rqg.c.country': 'Country', 'rqg.c.state': 'State', 'rqg.c.cnty': 'County', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Grid', 'rqg.c.gridsquare_ext': 'Grid Ext', 'rqg.h.gridsquare_ext': 'GridExt', 'rqg.c.vucc_grids': 'VUCC grids', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'SOTA ref', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'POTA ref', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Age', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'Email', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL sent', 'rqg.c.qsl_rcvd': 'QSL rcvd', 'rqg.c.qsl_sent_date': 'QSL sent date', 'rqg.h.qsl_sent_date': 'QSL S date', 'rqg.c.qsl_rcvd_date': 'QSL rcvd date', 'rqg.h.qsl_rcvd_date': 'QSL R date', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'QSL msg', 'rqg.c.qslmsg_rcvd': 'QSL msg rcvd', 'rqg.c.lotw_sent': 'LoTW sent', 'rqg.c.lotw_rcvd': 'LoTW rcvd', 'rqg.c.lotw_sent_date': 'LoTW sent date', 'rqg.h.lotw_sent_date': 'LoTW S date', 'rqg.c.lotw_rcvd_date': 'LoTW rcvd date', 'rqg.h.lotw_rcvd_date': 'LoTW R date', 'rqg.c.eqsl_sent': 'eQSL sent', 'rqg.c.eqsl_rcvd': 'eQSL rcvd', 'rqg.c.eqsl_sent_date': 'eQSL sent date', 'rqg.h.eqsl_sent_date': 'eQSL S date', 'rqg.c.eqsl_rcvd_date': 'eQSL rcvd date', 'rqg.h.eqsl_rcvd_date': 'eQSL R date', 'rqg.c.opslog_qsl_card_sent': 'OpsLog QSL', 'rqg.c.opslog_recording_sent': 'Recording sent', 'rqg.h.opslog_recording_sent': 'Rec sent', 'rqg.c.clublog_sent': 'ClubLog sent', 'rqg.c.clublog_sent_date': 'ClubLog sent date', 'rqg.h.clublog_sent_date': 'ClubLog S date', 'rqg.c.hrdlog_sent': 'HRDLog sent', 'rqg.c.hrdlog_sent_date': 'HRDLog sent date', 'rqg.h.hrdlog_sent_date': 'HRDLog S date', 'rqg.c.qrz_sent': 'QRZ.com sent', 'rqg.c.qrz_rcvd': 'QRZ.com rcvd', 'rqg.c.qrz_sent_date': 'QRZ.com sent date', 'rqg.h.qrz_sent_date': 'QRZ.com S date', 'rqg.c.qrz_rcvd_date': 'QRZ.com rcvd date', 'rqg.h.qrz_rcvd_date': 'QRZ.com R date', 'rqg.c.contest_id': 'Contest ID', 'rqg.h.contest_id': 'Contest', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'SRX string', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'STX string', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Precedence', 'rqg.c.arrl_sect': 'ARRL section', 'rqg.h.arrl_sect': 'ARRL sect', 'rqg.c.prop_mode': 'Prop mode', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Sat name', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Sat mode', 'rqg.c.ant_az': 'Ant az', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Ant el', 'rqg.h.ant_el': 'El', 'rqg.c.ant_path': 'Ant path', 'rqg.h.ant_path': 'Path', 'rqg.c.station_callsign': 'Station call', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Operator', 'rqg.c.my_grid': 'My grid', 'rqg.c.my_country': 'My country', 'rqg.h.my_country': 'My ctry', 'rqg.c.my_state': 'My state', 'rqg.c.my_cnty': 'My county', 'rqg.h.my_cnty': 'My cnty', 'rqg.c.my_iota': 'My IOTA', 'rqg.c.my_sota': 'My SOTA', 'rqg.c.my_pota': 'My POTA', 'rqg.c.my_dxcc': 'My DXCC', 'rqg.h.my_dxcc': 'My DXCC#', 'rqg.c.my_cq_zone': 'My CQ zone', 'rqg.h.my_cq_zone': 'My CQZ', 'rqg.c.my_itu_zone': 'My ITU zone', 'rqg.h.my_itu_zone': 'My ITU', 'rqg.c.my_lat': 'My lat', 'rqg.c.my_lon': 'My lon', 'rqg.c.my_street': 'My street', 'rqg.h.my_street': 'Street', 'rqg.c.my_city': 'My city', 'rqg.h.my_city': 'City', 'rqg.c.my_zip': 'My ZIP', 'rqg.h.my_zip': 'ZIP', 'rqg.c.my_rig': 'My rig', 'rqg.c.my_antenna': 'My antenna', 'rqg.h.my_antenna': 'My ant', 'rqg.c.comment': 'Comment', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Created', 'rqg.h.created': 'Created at', 'rqg.c.updated': 'Updated', 'rqg.h.updated': 'Updated at', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Contacted', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Uploads', 'rqg.grpContest': 'Contest', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'My station', 'rqg.grpMisc': 'Misc', 'rqg.grpAwards': 'Awards', 'rqg.awardTip': '{name} — reference this QSO counts for', 'rqg.clearFiltersTitle': 'Clear all column filters', 'rqg.clearFilters': 'Clear filters', 'rqg.columns': 'Columns', 'rqg.pickerDesc': 'Pick the columns you want visible in the Recent QSOs table. Your selection is saved.', 'rqg.all': 'all', 'rqg.none': 'none', 'rqg.resetDefaults': 'Reset to defaults', 'rqg.done': 'Done',
|
||||
'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done',
|
||||
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator', 'clg2.h.locator': 'Loc', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.wkdCall': 'WKD CALL', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
|
||||
};
|
||||
|
||||
const fr: Dict = {
|
||||
@@ -46,6 +210,8 @@ const fr: Dict = {
|
||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog',
|
||||
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
|
||||
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
|
||||
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
|
||||
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
|
||||
@@ -57,6 +223,147 @@ const fr: Dict = {
|
||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)',
|
||||
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
|
||||
'email.title': 'E-mail',
|
||||
'station.loading': 'Chargement du profil…',
|
||||
'station.hint': 'Édition du profil actif : {name}. Change de profil dans la section Profils pour en éditer un autre.',
|
||||
'station.stationCall': 'Indicatif station', 'station.stationCallHint': "Ce qui est émis (ADIF STATION_CALLSIGN).",
|
||||
'station.opCall': 'Indicatif opérateur', 'station.opCallHint': "Qui est à la radio (ADIF OPERATOR).",
|
||||
'station.ownerCall': 'Indicatif propriétaire', 'station.ownerBlank': '(vide si identique à la station)',
|
||||
'station.ownerHint': 'Propriétaire légal — ne diffère que pour les stations de club ou setups distants (ADIF STATION_OWNER).',
|
||||
'station.opName': "Nom de l'opérateur", 'station.opNameHint': 'Ton prénom — utilisé comme signature sur les cartes QSL.',
|
||||
'station.autofill': "Auto-rempli depuis l'indicatif — éditable (inscrit en MY_* sur chaque QSO)",
|
||||
'station.myGrid': 'Mon locator', 'station.myCountry': 'Mon pays', 'station.dxcc': 'DXCC #', 'station.cqz': 'Zone CQ', 'station.ituz': 'Zone ITU',
|
||||
'station.lat': 'Latitude', 'station.lon': 'Longitude', 'station.state': 'État / préf', 'station.county': 'Comté',
|
||||
'station.street': 'Adresse', 'station.postal': 'Code postal', 'station.city': 'Ville', 'station.sota': 'Réf SOTA', 'station.pota': 'Réf POTA',
|
||||
'conf.hint': 'Statut QSL / eQSL / LoTW / upload par défaut appliqué à chaque QSO enregistré — manuellement ou via auto-log UDP depuis WSJT-X / JTDX / MSHV. Laisse un champ vide pour garder la colonne du QSO vide.',
|
||||
'conf.sent': 'Envoyée', 'conf.rcvd': 'Reçue', 'conf.paperQsl': 'QSL papier',
|
||||
'conf.note': '« Envoyée » = statut d\'upload du QSO vers le service ; « N » est typique pour qu\'OpsLog suive les QSO restant à uploader. Club Log & HRDLog sont upload-only (pas de « reçue » en ADIF) ; QRZ.com a aussi « Reçue » = confirmé en retour.',
|
||||
'conf.blank': '— laisser vide —', 'conf.y': 'Y (oui)', 'conf.n': 'N (non)', 'conf.r': 'R (demandée)', 'conf.q': 'Q (en file)', 'conf.i': 'I (ignorer)',
|
||||
'udp.hint': 'Écoute les logs QSO de WSJT-X / JTDX / MSHV, les messages ADIF de JTAlert/GridTracker, ou de simples paquets d\'indicatif d\'outils externes. Les connexions sortantes transmettent chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).',
|
||||
'operating.hint': "Définis tes stations et les antennes utilisées sur chaque bande. Le bandeau de saisie remplit auto MY_RIG et MY_ANTENNA selon l'antenne par défaut de la bande.",
|
||||
'op.intro1': 'Définis tes stations et les antennes connectées à chacune. Pour chaque antenne, coche les bandes qu\'elle couvre. ',
|
||||
'op.intro2': ' marque l\'antenne par défaut de cette bande — quand tu changes de bande dans le bandeau, la station + antenne correspondantes remplissent auto les champs MY_RIG et MY_ANTENNA. Une seule antenne peut être par défaut par bande ; en définir une retire la précédente.',
|
||||
'op.addRig': 'Ajouter station', 'op.noRig': 'Aucune station configurée. Clique « Ajouter station » pour commencer.',
|
||||
'op.rigNamePh': 'Nom de la station (inscrit en MY_RIG)', 'op.powerW': 'Puissance (W)',
|
||||
'op.antenna': 'Antenne', 'op.deleteRig': 'Supprimer la station', 'op.noAntenna': 'Aucune antenne — clique « Antenne » ci-dessus pour en ajouter.',
|
||||
'op.antNamePh': "Nom de l'antenne (inscrit en MY_ANTENNA)", 'op.default': 'Défaut', 'op.set': 'Définir',
|
||||
'op.defaultOn': 'Antenne par défaut pour cette bande — clic pour retirer', 'op.defaultOff': 'Clic pour définir comme antenne par défaut pour cette bande',
|
||||
'op.collapse': 'Réduire', 'op.expand': 'Développer', 'op.deleteAntConfirm': 'Supprimer cette antenne ?', 'op.edit': 'Éditer',
|
||||
'lk.hint': 'Choisis un fournisseur Principal et un Secours optionnel (interrogé seulement si le Principal ne renvoie rien). Clique Test pour vérifier les identifiants sans enregistrer.',
|
||||
'lk.provider': 'Fournisseur', 'lk.primary': 'Principal', 'lk.failsafe': 'Secours', 'lk.user': 'Utilisateur', 'lk.password': 'Mot de passe', 'lk.result': 'Résultat',
|
||||
'lk.testing': 'Test…', 'lk.test': 'Test', 'lk.testTitle': "Lance une recherche test sur l'indicatif du profil actif pour vérifier les identifiants",
|
||||
'lk.failsafeNote': 'Le Secours n\'est consulté que si le Principal ne trouve rien ou échoue. Décoche les deux en contest pour éviter tout accès réseau.',
|
||||
'lk.display': 'Affichage', 'lk.showPics': 'Afficher les photos de profil QRZ',
|
||||
'lk.showPicsHint': 'Affiche la photo de QRZ.com à côté de la matrice « déjà contacté ». Peut ralentir les recherches en contest ; désactive si tu opères vite.',
|
||||
'lk.cache': 'Cache', 'lk.cacheHint': "Les recherches réussies sont mises en cache localement pour ne pas re-interroger le même indicatif. Le TTL contrôle la durée avant une nouvelle requête.",
|
||||
'lk.ttl': 'TTL (jours)', 'lk.clearing': 'Effacement…', 'lk.clearCache': 'Vider le cache',
|
||||
'bnd.hint': "Choisis les bandes que tu utilises vraiment. Le bandeau de saisie, la grille de bandes et le sélecteur de carte n'affichent que celles de droite. L'ordre à droite = ordre d'affichage.",
|
||||
'bnd.available': 'Disponibles', 'bnd.allSelected': 'Toutes les bandes du catalogue sont sélectionnées.', 'bnd.customPh': 'Bande perso (ex. 4m)',
|
||||
'bnd.selected': 'Sélectionnés ({n})', 'bnd.none': 'Aucune bande — choisis à gauche.',
|
||||
'mds.hint': 'Choisis à droite les modes que tu utilises. Partout où l\'interface propose un mode, elle parcourt la colonne de droite. En sélectionnant un mode dans le formulaire, RST envoyé/reçu se remplissent avec les valeurs par défaut ci-dessous (sauf si tu as tapé quelque chose).',
|
||||
'mds.allSelected': 'Tous les modes du catalogue sont sélectionnés.', 'mds.customPh': 'Mode perso', 'mds.order': 'Ordre', 'mds.mode': 'Mode',
|
||||
'mds.rstSnt': 'RST env', 'mds.rstRcv': 'RST reç', 'mds.none': 'Aucun mode — choisis à gauche.', 'mds.addBlank': 'Ajouter une ligne',
|
||||
'mds.defaultRstTitle': 'RST par défaut : {sent} / {rcvd}',
|
||||
'mds.rstLists': 'Listes de report RST', 'mds.rstListsHint': "Les choix proposés dans les menus RST du formulaire, par famille de mode. Une valeur par ligne (ou séparées par espaces). La première est en haut de la liste.",
|
||||
'mds.phone': 'Phonie (SSB/AM/FM)', 'mds.cw': 'CW / RTTY / PSK', 'mds.digital': 'Numérique (FT8/FT4/JT…) — dB',
|
||||
'clu.hint': 'Connecte-toi à un ou plusieurs nœuds DX cluster (telnet). Le premier serveur activé est le maître — les commandes tapées et d\'init passent par lui.',
|
||||
'clu.name': 'Nom', 'clu.hostPort': 'Hôte:port', 'clu.status': 'Statut', 'clu.actions': 'Actions',
|
||||
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.',
|
||||
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
|
||||
'clu.freeNodes': 'Nœuds publics gratuits :',
|
||||
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
|
||||
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
|
||||
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
||||
'bk.effective': 'Dossier effectif :', 'bk.defaultUse': 'Si vide, OpsLog utilise le défaut :', 'bk.rotation': 'Rotation (copies à garder)', 'bk.zip': 'Sauvegarde ZIP (fichier plus petit)',
|
||||
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans',
|
||||
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
|
||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).",
|
||||
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
|
||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
|
||||
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
||||
'cat.flexHint': "API SmartSDR native — pas besoin d'OmniRig. Fréquence, mode et split sont lus en temps réel depuis la radio (sans poll, sans le bug du mode au 2ᵉ clic). Utilise Détecter les radios ou saisis l'IP. Le mode numérique par défaut est ce qu'OpsLog logge quand la slice est en mode numérique (DIGU/DIGL).",
|
||||
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
||||
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
|
||||
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
||||
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
||||
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
|
||||
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
|
||||
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
|
||||
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
||||
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
||||
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
||||
'db.activeBackend': 'Backend actif :', 'db.logbook': 'Journal :', 'db.config': 'Config :', 'db.mysqlConnected': 'MySQL partagé connecté ✓', 'db.localSqlite': 'SQLite local',
|
||||
'db.fallback': "MySQL est activé mais la connexion a échoué au démarrage — OpsLog tourne sur la base SQLite locale.",
|
||||
'db.mysqlNote': "Seuls les QSO vont dans MySQL ; tes réglages, profils, stations et cluster restent locaux (et rapides). Les QSO locaux existants ne sont pas copiés — importe-les dans le journal partagé si tu veux ton historique là-bas.",
|
||||
'em.none': 'Aucun', 'em.smtpAuth': 'Le SMTP requiert une authentification', 'em.username': 'Utilisateur', 'em.fromAddr': 'Adresse expéditeur', 'em.replyTo': 'Adresse de réponse', 'em.replyToPh': '(optionnel — où vont les réponses)', 'em.replyToHint': "Vide = utilise l'adresse expéditeur. Renseigne-la pour que les correspondants répondent p. ex. sur ta boîte perso.",
|
||||
'em.sendTest': 'Envoyer un e-mail test', 'em.sendingTest': 'Envoi du test…', 'em.testSent': 'E-mail test envoyé ✓', 'em.testFailed': 'Échec du test : ',
|
||||
'em.qslCardEmail': 'E-mail de carte QSL OpsLog', 'em.qslVarsHint': 'Message envoyé avec la carte QSL. Variables :', 'em.subject': 'Objet', 'em.body': 'Corps', 'em.autoSend': "Envoyer auto la QSL OpsLog à l'enregistrement d'un QSO", 'em.autoSendHint': "Envoi automatique uniquement si le contact a une adresse e-mail et qu'un modèle QSL par défaut existe.",
|
||||
'settings.title': 'Préférences',
|
||||
'btn.cancel': 'Annuler', 'btn.save': 'Enregistrer', 'btn.saveClose': 'Enregistrer et fermer', 'btn.savingLong': 'Enregistrement…',
|
||||
'chatp.chat': 'Chat', 'chatp.online': 'En ligne', 'chatp.close': 'Fermer', 'chatp.noMessages': 'Aucun message pour le moment.', 'chatp.messagePh': 'Message…',
|
||||
'chp.workedBefore': 'Déjà contacté', 'chp.typeCallsign': 'Saisis un indicatif pour voir les contacts précédents.', 'chp.checking': 'vérification…', 'chp.new': 'NOUVEAU',
|
||||
'chp.noPriorQso': 'Aucun QSO antérieur avec cet indicatif.', 'chp.first': 'Premier', 'chp.last': 'Dernier', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Bande', 'chp.mode': 'Mode',
|
||||
'chp.lotwRcvd': 'LoTW reçue', 'chp.bureauRcvd': 'Bureau reçue', 'chp.olderQsos': '+ {n} QSO plus anciens',
|
||||
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)',
|
||||
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
||||
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
|
||||
'ctp.contestSetup': 'Configuration du contest', 'ctp.live': 'En direct', 'ctp.chooseContest': '— choisir un contest —', 'ctp.serial': 'Numéro de série', 'ctp.fixedExchange': 'Échange fixe', 'ctp.nextSerial': 'Prochain numéro', 'ctp.yourExchangePh': 'ton échange', 'ctp.startUtc': 'Début (UTC)', 'ctp.endUtc': 'Fin (UTC)', 'ctp.windowHint': 'fin vide = ouvert · laisser vide pour tout compter',
|
||||
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
||||
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
||||
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
||||
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
|
||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto',
|
||||
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
||||
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "Base mise à jour → notifier d'autres apps", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
||||
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
||||
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
|
||||
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
|
||||
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
|
||||
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator', 'clg2.h.locator': 'Loc', 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.wkdCall': 'DÉJÀ CTC', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
|
||||
};
|
||||
|
||||
const dicts: Record<Lang, Dict> = { en, fr };
|
||||
|
||||
Reference in New Issue
Block a user