feat: Adding French language

This commit is contained in:
2026-07-05 03:07:44 +02:00
parent e590a58702
commit 3a6afc28ac
11 changed files with 696 additions and 171 deletions
+84 -55
View File
@@ -8,6 +8,7 @@ import {
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
ContestDupe,
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
@@ -68,7 +69,8 @@ import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { ContestBar, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestBar';
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
import { useI18n } from '@/lib/i18n';
import { AlertsModal } from '@/components/AlertsModal';
import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
@@ -247,6 +249,7 @@ function computePrefix(call: string): string {
}
export default function App() {
const { t } = useI18n();
// === Lists from settings (fallback for first paint) ===
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
@@ -345,6 +348,7 @@ export default function App() {
// exchange for the current QSO.
const [contest, setContest] = useState<ContestSession>(CONTEST_DEFAULT);
const [rcv, setRcv] = useState('');
const [contestDupe, setContestDupe] = useState(false);
const [country, setCountry] = useState('');
const [comment, setComment] = useState('');
const [note, setNote] = useState('');
@@ -595,6 +599,18 @@ export default function App() {
return next;
});
};
// Contest dupe check: is the typed callsign already worked in this contest on
// the current band + mode? Debounced so it doesn't fire on every keystroke.
useEffect(() => {
if (!contest.active || !contest.code || !callsign.trim()) { setContestDupe(false); return; }
const call = callsign.trim().toUpperCase();
let alive = true;
const t = window.setTimeout(() => {
ContestDupe(call, contest.code, band, mode, contest.startISO).then((d) => { if (alive) setContestDupe(!!d); }).catch(() => {});
}, 250);
return () => { alive = false; window.clearTimeout(t); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [callsign, contest.active, contest.code, band, mode, contest.startISO]);
// === DX Cluster live state ===
type ClusterSpot = {
@@ -2278,36 +2294,36 @@ export default function App() {
}
const menus: Menu[] = useMemo(() => [
{ name: 'file', label: 'File', items: [
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
{ type: 'item', label: exporting ? 'Exporting' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
{ type: 'item', label: 'Export Cabrillo', action: 'file.exportCabrillo', disabled: total === 0 },
{ name: 'file', label: t('menu.file'), items: [
{ type: 'item', label: t('file.import'), action: 'file.import', shortcut: 'Ctrl+O' },
{ type: 'item', label: exporting ? t('file.exporting') : t('file.export'), action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
{ type: 'item', label: t('file.exportCabrillo'), action: 'file.exportCabrillo', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Exit', action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
]},
{ name: 'edit', label: 'Edit', items: [
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
{ name: 'edit', label: t('menu.edit'), items: [
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
{ type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
{ type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
{ type: 'item', label: t('edit.prefs'), action: 'edit.prefs' },
]},
{ name: 'view', label: 'View', items: [
{ type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
{ name: 'view', label: t('menu.view'), items: [
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
]},
{ name: 'tools', label: 'Tools', items: [
{ type: 'item', label: 'QSL Manager', action: 'tools.qslmanager' },
{ type: 'item', label: 'QSL Card Designer', action: 'tools.qsldesigner' },
{ name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' },
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
{ type: 'separator' },
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
{ type: 'item', label: 'Alert management…', action: 'tools.alerts' },
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
{ type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
@@ -2317,7 +2333,7 @@ export default function App() {
{ name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled]);
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, t]);
function handleMenu(action: string) {
switch (action) {
@@ -2447,8 +2463,8 @@ export default function App() {
// handlers across the two layouts.
const callsignBlock = (
<div className="flex flex-col w-44">
<Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')}
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>}
{!lookupBusy && lookupResult && (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
@@ -2460,6 +2476,11 @@ export default function App() {
)}
</Label>
<div className="relative">
{contest.active && contestDupe && (
<span className="absolute -top-2 right-1 z-20 rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-black tracking-widest text-white shadow animate-pulse pointer-events-none">
DUPE
</span>
)}
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<button
type="button"
@@ -2474,7 +2495,8 @@ export default function App() {
)}
<Input
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
contest.active && contestDupe && 'ring-2 ring-red-500 !bg-red-50 focus:!bg-red-50 text-red-700')}
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
onBlur={() => {
@@ -2490,12 +2512,12 @@ export default function App() {
</div>
);
const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
);
@@ -2529,7 +2551,7 @@ export default function App() {
) : null;
const startBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">Start UTC <LockBtn k="start" title="start time" /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
<Input
readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1}
@@ -2548,7 +2570,7 @@ export default function App() {
);
const endBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-rose-700">End UTC <LockBtn k="end" title="end time" /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-rose-700">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
@@ -2571,7 +2593,7 @@ export default function App() {
</div>
);
const nameBlock = (
<div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">Name</Label>
<div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
);
@@ -2582,21 +2604,21 @@ export default function App() {
: (contest.sentFixed || '—');
const qthBlock = contest.active ? (
<>
<div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">Snt</Label>
<div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label>
<Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
</div>
<div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-amber-600 font-bold">Rcv</Label>
<div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-amber-600 font-bold">{t('field.rcv')}</Label>
<Input value={rcv} placeholder="exch" className="font-mono"
onChange={(e) => setRcv(e.target.value.toUpperCase())} />
</div>
</>
) : (
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
);
const gridBlock = (
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">Grid</Label>
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
);
@@ -2614,7 +2636,7 @@ export default function App() {
</div>
);
const commentSm = (
<div className="flex flex-col w-40"><Label className="mb-1 h-3.5">Comment</Label>
<div className="flex flex-col w-40"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
);
@@ -2622,7 +2644,7 @@ export default function App() {
// used in the full layout to save vertical height.
const bandRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockBtn k="band" title="band" /></Label>
<div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -2633,7 +2655,7 @@ export default function App() {
);
const modeRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockBtn k="mode" title="mode" /></Label>
<div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -2644,7 +2666,7 @@ export default function App() {
);
const countryRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Country</Label>
<Label className="w-20 shrink-0">{t('field.country')}</Label>
<div className="flex-1 min-w-0">
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
</div>
@@ -2653,7 +2675,7 @@ export default function App() {
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
const freqBlock = (
<div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">TX Freq (MHz) <LockBtn k="freq" title="frequency" /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
<Input
tabIndex={-1}
className="font-mono"
@@ -2667,7 +2689,7 @@ export default function App() {
);
const rxFreqBlock = (
<div className="flex flex-col w-32">
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>RX Freq (MHz)</Label>
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>{t('field.rxFreq')}</Label>
<Input
tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
@@ -2680,7 +2702,7 @@ export default function App() {
</div>
);
const bandRxBlock = (
<div className="flex flex-col w-28"><Label className="mb-1 h-3.5">RX Band</Label>
<div className="flex flex-col w-28"><Label className="mb-1 h-3.5">{t('field.rxBand')}</Label>
<Select value={bandRx} onValueChange={setBandRx}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
@@ -2690,12 +2712,12 @@ export default function App() {
// Single-line Comment/Note for the full layout (stacked in the right
// column). No flex-1 so they stay one row tall.
const commentLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">Comment</Label>
<div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
);
const noteLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">Note</Label>
<div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.note')}</Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} />
</div>
);
@@ -2706,17 +2728,17 @@ export default function App() {
{clusterServerStatuses.some((s) => s.state === 'connected') && (
<Button type="button" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8" title="Send a DX spot to the master cluster">
<Satellite className="size-3.5" />
Spot
{t('btn.spot')}
</Button>
)}
<Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
<Eraser className="size-3.5" />
Clear
{t('btn.clear')}
</Button>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
{saving ? '…' : t('btn.logQso')}
</Button>
</div>
</div>
@@ -2728,7 +2750,7 @@ export default function App() {
<Label className="mb-1 h-3.5">&nbsp;</Label>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
{saving ? '…' : t('btn.logQso')}
</Button>
</div>
);
@@ -3687,28 +3709,31 @@ export default function App() {
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <>
<ContestBar session={contest} onChange={updateContest} />
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
<TabsTrigger value="main">Main</TabsTrigger>
<TabsTrigger value="main">{t('tab.main')}</TabsTrigger>
<TabsTrigger value="recent">
Recent QSOs
{t('tab.recent')}
</TabsTrigger>
<TabsTrigger value="cluster">Cluster</TabsTrigger>
<TabsTrigger value="cluster">{t('tab.cluster')}</TabsTrigger>
<TabsTrigger value="worked">
Worked before
{t('tab.worked')}
{wb && wb.count > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
<TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger>
<TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger>
<TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-amber-600 data-[state=active]:text-amber-600')}>
{t('tab.contest')}
{contest.active && <span className="inline-block size-1.5 rounded-full bg-amber-500 animate-pulse" />}
</TabsTrigger>
{netEnabled && (
<TabsTrigger value="net" className="gap-1.5">
Net
{t('tab.net')}
<span
role="button"
aria-label="Close Net"
@@ -4031,6 +4056,10 @@ export default function App() {
<AwardsPanel onEditQSO={openEdit} />
</TabsContent>
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
<ContestPanel session={contest} onChange={updateContest} />
</TabsContent>
{/* FlexRadio SmartSDR-style control panel — only present when the CAT
backend is a FlexRadio. */}
{catState.backend === 'flex' && (