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
+164
View File
@@ -3775,6 +3775,170 @@ func (a *App) ListContests() []contest.Def {
return contest.List()
}
// ContestBandRow is one band's QSO count in the contest scoreboard.
type ContestBandRow struct {
Band string `json:"band"`
Count int `json:"count"`
}
// ContestStatsResult is the live contest scoreboard. Score is a best-effort
// ESTIMATE (qsos × mult): real contest scoring is per-contest and rule-heavy, so
// this is a working indicator, not an official claim.
type ContestStatsResult struct {
QSOs int `json:"qsos"`
ByBand []ContestBandRow `json:"by_band"`
Mult int `json:"mult"`
MultLabel string `json:"mult_label"`
Score int `json:"score"`
LastHour int `json:"last_hour"` // QSOs in the last 60 min (rate)
}
// parseTimeISO parses an RFC3339(/nano) timestamp; ok=false for an empty/invalid
// value (meaning "no bound").
func parseTimeISO(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, true
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, true
}
return time.Time{}, false
}
// ContestStats computes the scoreboard for the given CONTEST_ID within the
// [startISO, endISO] window (either bound may be empty = open). exchange is the
// active exchange kind, which decides what counts as a multiplier.
func (a *App) ContestStats(contestID, exchange, startISO, endISO string) ContestStatsResult {
res := ContestStatsResult{}
if a.qso == nil || strings.TrimSpace(contestID) == "" {
return res
}
start, hasStart := parseTimeISO(startISO)
end, hasEnd := parseTimeISO(endISO)
f := qso.QueryFilter{
Conditions: []qso.Condition{{Field: "contest_id", Op: "eq", Value: contestID}},
Match: "AND", Limit: 1_000_000,
}
bandCount := map[string]int{}
multSet := map[string]struct{}{}
label := "DXCC"
cutoff := time.Now().Add(-time.Hour)
_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
if hasStart && q.QSODate.Before(start) {
return nil
}
if hasEnd && q.QSODate.After(end) {
return nil
}
res.QSOs++
if q.Band != "" {
bandCount[q.Band]++
}
if !q.QSODate.IsZero() && q.QSODate.After(cutoff) {
res.LastHour++
}
switch exchange {
case "cq-zone":
label = "Zones"
if q.CQZ != nil && *q.CQZ > 0 {
multSet[fmt.Sprintf("Z%d", *q.CQZ)] = struct{}{}
}
case "itu-zone":
label = "Zones"
if q.ITUZ != nil && *q.ITUZ > 0 {
multSet[fmt.Sprintf("I%d", *q.ITUZ)] = struct{}{}
}
case "dept", "state", "name", "grid", "age", "section":
label = "Mults"
k := strings.ToUpper(strings.TrimSpace(q.SRXString))
if k == "" && q.SRX != nil {
k = fmt.Sprintf("%d", *q.SRX)
}
if k != "" {
multSet[k] = struct{}{}
}
default: // serial and others
if strings.Contains(strings.ToUpper(contestID), "WPX") {
label = "Prefixes"
if p := award.WPXPrefix(q.Callsign); p != "" {
multSet[p] = struct{}{}
}
} else {
label = "DXCC"
if q.DXCC != nil && *q.DXCC > 0 {
multSet[fmt.Sprintf("%d", *q.DXCC)] = struct{}{}
}
}
}
return nil
})
res.Mult = len(multSet)
res.MultLabel = label
if res.Mult > 0 {
res.Score = res.QSOs * res.Mult
} else {
res.Score = res.QSOs
}
for b, c := range bandCount {
res.ByBand = append(res.ByBand, ContestBandRow{Band: b, Count: c})
}
if res.ByBand == nil {
res.ByBand = []ContestBandRow{} // never nil → marshals as [] not null (frontend guard)
}
sort.Slice(res.ByBand, func(i, j int) bool { return res.ByBand[i].Band < res.ByBand[j].Band })
return res
}
// ContestDupe reports whether call was already worked in this contest on the
// same band + mode class since startISO (the usual dupe rule). Empty band/mode/
// startISO widens the check.
func (a *App) ContestDupe(call, contestID, band, mode, startISO string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" || strings.TrimSpace(contestID) == "" {
return false
}
start, hasStart := parseTimeISO(startISO)
f := qso.QueryFilter{
Conditions: []qso.Condition{
{Field: "contest_id", Op: "eq", Value: contestID},
{Field: "callsign", Op: "eq", Value: strings.ToUpper(strings.TrimSpace(call))},
},
Match: "AND", Limit: 1000,
}
if strings.TrimSpace(band) != "" {
f.Conditions = append(f.Conditions, qso.Condition{Field: "band", Op: "eq", Value: band})
}
want := contestModeClass(mode)
dupe := false
_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
if hasStart && q.QSODate.Before(start) {
return nil
}
if want == "" || contestModeClass(q.Mode) == want {
dupe = true
}
return nil
})
return dupe
}
// contestModeClass collapses a mode to the CW/PH/DIG bucket contests dupe on.
func contestModeClass(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "":
return ""
case "CW":
return "CW"
case "SSB", "USB", "LSB", "AM", "FM", "PH", "PHONE":
return "PH"
default:
return "DIG"
}
}
func (a *App) ExportCabrillo(path string) (CabrilloResult, error) {
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
}
+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' && (
-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
+143
View File
@@ -0,0 +1,143 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { writeUiPref } from './uiPref';
// Lightweight i18n: a flat key→string dictionary per language, a context that
// holds the active language, and a t() lookup with {var} interpolation. English
// is the source/fallback; a missing French key falls back to English, then to
// the key itself. Language is persisted (localStorage + portable UI pref) and
// chosen once at first run via the flag gate below.
export type Lang = 'en' | 'fr';
const LS_KEY = 'opslog.lang';
type Dict = Record<string, string>;
const en: Dict = {
// Menu bar
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
'edit.editSel': 'Edit selected QSO…', 'edit.prefs': 'Preferences…',
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
'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…',
// 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',
// Entry form
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'TX Freq (MHz)', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser
'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.',
};
const fr: Dict = {
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
'edit.editSel': 'Éditer le QSO sélectionné…', 'edit.prefs': 'Préférences…',
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
'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…',
'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',
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'Fréq TX (MHz)', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'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.",
};
const dicts: Record<Lang, Dict> = { en, fr };
function translate(lang: Lang, key: string, vars?: Record<string, string | number>): string {
let s = dicts[lang][key] ?? dicts.en[key] ?? key;
if (vars) for (const k of Object.keys(vars)) s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(vars[k]));
return s;
}
type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (key: string, vars?: Record<string, string | number>) => string; chosen: boolean };
const I18nContext = createContext<Ctx>({ lang: 'en', setLang: () => {}, t: (k) => k, chosen: true });
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => ((localStorage.getItem(LS_KEY) as Lang) || 'en'));
const [chosen, setChosen] = useState<boolean>(() => !!localStorage.getItem(LS_KEY));
const setLang = (l: Lang) => {
setLangState(l);
setChosen(true);
localStorage.setItem(LS_KEY, l);
writeUiPref(LS_KEY, l);
};
const t = useCallback((key: string, vars?: Record<string, string | number>) => translate(lang, key, vars), [lang]);
return (
<I18nContext.Provider value={{ lang, setLang, t, chosen }}>
{children}
{!chosen && <LanguageGate onPick={setLang} />}
</I18nContext.Provider>
);
}
export const useI18n = () => useContext(I18nContext);
// ── Flags (inline SVG — Windows doesn't render flag emoji) ──────────────────
export function FlagFR({ className }: { className?: string }) {
return (
<svg viewBox="0 0 3 2" className={className} aria-hidden>
<rect width="1" height="2" x="0" fill="#0055A4" />
<rect width="1" height="2" x="1" fill="#fff" />
<rect width="1" height="2" x="2" fill="#EF4135" />
</svg>
);
}
export function FlagGB({ className }: { className?: string }) {
return (
<svg viewBox="0 0 60 30" className={className} aria-hidden>
<clipPath id="ujs"><path d="M0,0 v30 h60 v-30 z" /></clipPath>
<clipPath id="ujt"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z" /></clipPath>
<g clipPath="url(#ujs)">
<path d="M0,0 v30 h60 v-30 z" fill="#012169" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#ujt)" stroke="#C8102E" strokeWidth="4" />
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10" />
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6" />
</g>
</svg>
);
}
// LanguageGate — first-run modal to pick the interface language.
function LanguageGate({ onPick }: { onPick: (l: Lang) => void }) {
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="rounded-2xl border border-border bg-card shadow-2xl p-8 w-[420px] max-w-[90vw] text-center">
<div className="text-lg font-bold mb-1">Choose your language</div>
<div className="text-sm text-muted-foreground mb-6">Choisissez votre langue</div>
<div className="grid grid-cols-2 gap-4">
<button type="button" onClick={() => onPick('en')}
className="flex flex-col items-center gap-3 rounded-xl border-2 border-border p-5 hover:border-primary hover:bg-muted transition-colors">
<FlagGB className="w-16 rounded shadow-sm" />
<span className="font-bold">English</span>
</button>
<button type="button" onClick={() => onPick('fr')}
className="flex flex-col items-center gap-3 rounded-xl border-2 border-border p-5 hover:border-primary hover:bg-muted transition-colors">
<FlagFR className="w-16 rounded shadow-sm border border-border/40" />
<span className="font-bold">Français</span>
</button>
</div>
</div>
</div>
);
}
+3
View File
@@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client'
import './style.css'
import App from './App'
import { syncPortablePrefs } from './lib/uiPref'
import { I18nProvider } from './lib/i18n'
const container = document.getElementById('root')
@@ -14,7 +15,9 @@ const root = createRoot(container!)
syncPortablePrefs().finally(() => {
root.render(
<React.StrictMode>
<I18nProvider>
<App/>
</I18nProvider>
</React.StrictMode>
)
})
+4
View File
@@ -68,6 +68,10 @@ export function ConnectAllClusters():Promise<void>;
export function ConnectClusterServer(arg1:number):Promise<void>;
export function ContestDupe(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string):Promise<boolean>;
export function ContestStats(arg1:string,arg2:string,arg3:string,arg4:string):Promise<main.ContestStatsResult>;
export function CountQSO():Promise<number>;
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
+8
View File
@@ -98,6 +98,14 @@ export function ConnectClusterServer(arg1) {
return window['go']['main']['App']['ConnectClusterServer'](arg1);
}
export function ContestDupe(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['ContestDupe'](arg1, arg2, arg3, arg4, arg5);
}
export function ContestStats(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ContestStats'](arg1, arg2, arg3, arg4);
}
export function CountQSO() {
return window['go']['main']['App']['CountQSO']();
}
+54
View File
@@ -1376,6 +1376,60 @@ export namespace main {
this.count = source["count"];
}
}
export class ContestBandRow {
band: string;
count: number;
static createFrom(source: any = {}) {
return new ContestBandRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.band = source["band"];
this.count = source["count"];
}
}
export class ContestStatsResult {
qsos: number;
by_band: ContestBandRow[];
mult: number;
mult_label: string;
score: number;
last_hour: number;
static createFrom(source: any = {}) {
return new ContestStatsResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.qsos = source["qsos"];
this.by_band = this.convertValues(source["by_band"], ContestBandRow);
this.mult = source["mult"];
this.mult_label = source["mult_label"];
this.score = source["score"];
this.last_hour = source["last_hour"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class CtyDatInfo {
path: string;
entities: number;
+4
View File
@@ -2,6 +2,10 @@ package award
import "strings"
// WPXPrefix is the exported entry point for deriving a callsign's CQ WPX prefix
// (used by contest-mode multiplier counting).
func WPXPrefix(call string) string { return wpxPrefix(call) }
// wpxPrefix derives the CQ WPX prefix from a callsign. This is an approximation
// of the official WPX rules — good enough to count distinct prefixes worked:
// - standard call: letters+digits up to and including the LAST digit of the