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
-115
View File
@@ -1,115 +0,0 @@
import { useEffect, useState } from 'react';
import { ListContests } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
// so a running contest survives a restart (serial counter included).
export type ContestSession = {
active: boolean;
code: string; // ADIF CONTEST_ID written to each QSO
name: string; // display name
exchange: 'serial' | 'fixed';
fixedKind: string; // label for the fixed exchange (CQ Zone, Département…)
sentFixed: string; // the value YOU send when the exchange is fixed
nextSerial: number; // running sent serial number
};
export const CONTEST_DEFAULT: ContestSession = {
active: false, code: '', name: '', exchange: 'serial',
fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1,
};
const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom'];
// Map the catalogue's default exchange hint onto a fixed-kind label.
const KIND_LABEL: Record<string, string> = {
'cq-zone': 'CQ Zone', 'itu-zone': 'ITU Zone', 'state': 'State/Prov',
'dept': 'Département', 'grid': 'Grid', 'name': 'Name', 'class-section': 'Section',
};
type Def = { code: string; name: string; exchange: string };
export function ContestBar({ session, onChange }: {
session: ContestSession;
onChange: (patch: Partial<ContestSession>) => void;
}) {
const [contests, setContests] = useState<Def[]>([]);
useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []);
const pickContest = (code: string) => {
const def = contests.find((c) => c.code === code);
if (!def) { onChange({ code: '', name: '' }); return; }
if (def.exchange === 'serial' || def.exchange === 'rst') {
onChange({ code: def.code, name: def.name, exchange: 'serial' });
} else {
onChange({ code: def.code, name: def.name, exchange: 'fixed', fixedKind: KIND_LABEL[def.exchange] || 'Custom' });
}
};
if (!session.active) {
return (
<div className="flex items-center px-3 py-1.5">
<button type="button" onClick={() => onChange({ active: true })}
className="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-bold text-muted-foreground hover:bg-muted">
Contest mode
</button>
</div>
);
}
const snt = session.exchange === 'serial'
? String(session.nextSerial).padStart(3, '0')
: (session.sentFixed || '—');
return (
<div className="flex items-center gap-2 px-3 py-1.5 border-y-2 border-amber-500 bg-amber-500/10 flex-wrap">
<span className="rounded bg-amber-500 px-2 py-0.5 text-[11px] font-black uppercase tracking-wider text-white shrink-0">
Contest
</span>
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
className="rounded-md border border-border bg-card px-2 py-1 text-xs max-w-[260px]">
<option value=""> pick contest </option>
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</select>
{session.code && <span className="text-[11px] font-mono text-muted-foreground shrink-0">{session.code}</span>}
{/* Serial vs fixed exchange. */}
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{(['serial', 'fixed'] as const).map((x) => (
<button key={x} type="button" onClick={() => onChange({ exchange: x })}
className={cn('px-2 py-1 text-[11px] 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'}
</button>
))}
</div>
{session.exchange === 'serial' ? (
<label className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
Next#
<input type="number" min={1} value={session.nextSerial}
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
className="w-16 rounded-md border border-border bg-card px-1.5 py-1 text-xs font-mono" />
</label>
) : (
<div className="flex items-center gap-2 shrink-0">
<select value={session.fixedKind} onChange={(e) => onChange({ fixedKind: e.target.value })}
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
{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 exch" className="w-24 rounded-md border border-border bg-card px-2 py-1 text-xs font-mono" />
</div>
)}
<span className="text-[11px] text-muted-foreground shrink-0">
Snt <b className="font-mono text-foreground">{snt}</b>
</span>
<button type="button" onClick={() => onChange({ active: false })}
className="ml-auto rounded-md border border-border bg-card px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted shrink-0">
Exit
</button>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { Trophy } from 'lucide-react';
import { ListContests, ContestStats } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
// so a running contest (serial counter included) survives a restart.
export type ContestSession = {
active: boolean;
code: string; // ADIF CONTEST_ID written to each QSO
name: string;
exchange: 'serial' | 'fixed';
fixedKind: string; // label for the fixed exchange (CQ Zone, Département…)
sentFixed: string; // the value YOU send when the exchange is fixed
nextSerial: number; // running sent serial number
startISO: string; // contest window start (UTC ISO) — only QSOs after count
endISO: string; // contest window end (UTC ISO, empty = open)
};
export const CONTEST_DEFAULT: ContestSession = {
active: false, code: '', name: '', exchange: 'serial',
fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1,
startISO: '', endISO: '',
};
// Contests run on UTC, so the date/time inputs are treated as UTC.
const utcInputToISO = (v: string) => (v ? new Date(v + ':00Z').toISOString() : '');
const isoToUtcInput = (iso: string) => (iso ? new Date(iso).toISOString().slice(0, 16) : '');
const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom'];
const KIND_LABEL: Record<string, string> = {
'cq-zone': 'CQ Zone', 'itu-zone': 'ITU Zone', 'state': 'State/Prov',
'dept': 'Département', 'grid': 'Grid', 'name': 'Name', 'class-section': 'Section',
};
type Def = { code: string; name: string; exchange: string };
type Stats = { qsos: number; by_band?: { band: string; count: number }[] | null; mult: number; mult_label: string; score: number; last_hour: number };
function Stat({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="rounded-lg border border-border bg-card px-3 py-2 text-center">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="text-xl font-black tabular-nums" style={accent ? { color: accent } : undefined}>{value}</div>
</div>
);
}
export function ContestPanel({ session, onChange }: {
session: ContestSession;
onChange: (patch: Partial<ContestSession>) => void;
}) {
const [contests, setContests] = useState<Def[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []);
// Poll the scoreboard while a contest is running.
useEffect(() => {
if (!session.active || !session.code) { setStats(null); return; }
let alive = true;
const load = () => ContestStats(
session.code,
session.exchange === 'fixed' ? kindKey(session.fixedKind) : 'serial',
session.startISO, session.endISO,
).then((s) => { if (alive) setStats(s as Stats); }).catch(() => {});
load();
const id = window.setInterval(load, 4000);
return () => { alive = false; window.clearInterval(id); };
}, [session.active, session.code, session.exchange, session.fixedKind, session.startISO, session.endISO]);
const pickContest = (code: string) => {
const def = contests.find((c) => c.code === code);
if (!def) { onChange({ code: '', name: '' }); return; }
if (def.exchange === 'serial' || def.exchange === 'rst') {
onChange({ code: def.code, name: def.name, exchange: 'serial' });
} else {
onChange({ code: def.code, name: def.name, exchange: 'fixed', fixedKind: KIND_LABEL[def.exchange] || 'Custom' });
}
};
return (
<div className="h-full overflow-y-auto p-4 space-y-4">
{/* Setup card. */}
<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>}
</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>
{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>}
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-md border border-border overflow-hidden">
{(['serial', 'fixed'] as const).map((x) => (
<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'}
</button>
))}
</div>
{session.exchange === 'serial' ? (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Next serial
<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" />
</label>
) : (
<div className="flex items-center gap-2">
<select value={session.fixedKind} onChange={(e) => onChange({ fixedKind: e.target.value })}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm">
{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" />
</div>
)}
</div>
{/* Contest window (UTC). Only QSOs inside it count toward the score and
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)
<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)
<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>
</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
</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
</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.'}
</span>
</div>
</div>
</div>
{/* Scoreboard. */}
{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>
</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} />
</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>
</tr></thead>
<tbody>
{stats.by_band.map((r) => (
<tr key={r.band} className="border-t border-border/60">
<td className="px-3 py-1 font-mono">{r.band}</td>
<td className="px-3 py-1 text-right tabular-nums">{r.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
);
}
// kindKey maps a fixed-kind label back to the backend's exchange key for mult
// counting.
function kindKey(label: string): string {
const m: Record<string, string> = {
'CQ Zone': 'cq-zone', 'ITU Zone': 'itu-zone', 'State/Prov': 'state',
'Département': 'dept', 'Grid': 'grid', 'Name': 'name', 'Section': 'section', 'Age': 'age',
};
return m[label] || 'state';
}
+16
View File
@@ -58,6 +58,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { OperatingPanel } from '@/components/OperatingPanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
@@ -3758,10 +3759,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function GeneralPanel() {
const { lang, setLang, t } = useI18n();
return (
<>
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
<div className="space-y-3 max-w-3xl">
<div className="flex items-center gap-3">
<Label className="text-sm w-40">{t('settings.language')}</Label>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => (
<button key={code} type="button" onClick={() => setLang(code)}
className={cn('flex items-center gap-2 px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0',
lang === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
<Flag className="w-5 rounded-[2px] border border-border/30" />
{label}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
Auto-focus "Worked before" for known stations