feat: Adding French language
This commit is contained in:
@@ -3775,6 +3775,170 @@ func (a *App) ListContests() []contest.Def {
|
|||||||
return contest.List()
|
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) {
|
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) })
|
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-55
@@ -8,6 +8,7 @@ import {
|
|||||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||||
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||||
|
ContestDupe,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
@@ -68,7 +69,8 @@ import { ClusterGrid } from '@/components/ClusterGrid';
|
|||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
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 { AlertsModal } from '@/components/AlertsModal';
|
||||||
import { BulkEditModal } from '@/components/BulkEditModal';
|
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||||
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
||||||
@@ -247,6 +249,7 @@ function computePrefix(call: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { t } = useI18n();
|
||||||
// === Lists from settings (fallback for first paint) ===
|
// === Lists from settings (fallback for first paint) ===
|
||||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||||
@@ -345,6 +348,7 @@ export default function App() {
|
|||||||
// exchange for the current QSO.
|
// exchange for the current QSO.
|
||||||
const [contest, setContest] = useState<ContestSession>(CONTEST_DEFAULT);
|
const [contest, setContest] = useState<ContestSession>(CONTEST_DEFAULT);
|
||||||
const [rcv, setRcv] = useState('');
|
const [rcv, setRcv] = useState('');
|
||||||
|
const [contestDupe, setContestDupe] = useState(false);
|
||||||
const [country, setCountry] = useState('');
|
const [country, setCountry] = useState('');
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
@@ -595,6 +599,18 @@ export default function App() {
|
|||||||
return next;
|
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 ===
|
// === DX Cluster live state ===
|
||||||
type ClusterSpot = {
|
type ClusterSpot = {
|
||||||
@@ -2278,36 +2294,36 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menus: Menu[] = useMemo(() => [
|
const menus: Menu[] = useMemo(() => [
|
||||||
{ name: 'file', label: 'File', items: [
|
{ name: 'file', label: t('menu.file'), items: [
|
||||||
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
|
{ type: 'item', label: t('file.import'), 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: exporting ? t('file.exporting') : t('file.export'), action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
|
||||||
{ type: 'item', label: 'Export Cabrillo…', action: 'file.exportCabrillo', disabled: total === 0 },
|
{ type: 'item', label: t('file.exportCabrillo'), action: 'file.exportCabrillo', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ 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: '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: [
|
{ name: 'edit', label: t('menu.edit'), items: [
|
||||||
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
{ 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 ? `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: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
{ type: 'item', label: t('edit.prefs'), action: 'edit.prefs' },
|
||||||
]},
|
]},
|
||||||
{ name: 'view', label: 'View', items: [
|
{ name: 'view', label: t('menu.view'), items: [
|
||||||
{ type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' },
|
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
|
||||||
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
|
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
|
||||||
]},
|
]},
|
||||||
{ name: 'tools', label: 'Tools', items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: 'QSL Card Designer…', action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
||||||
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
|
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
|
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||||
{ type: 'item', label: 'Alert management…', action: 'tools.alerts' },
|
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
// Maintenance — bumped here while we only have one entry. Will move
|
// Maintenance — bumped here while we only have one entry. Will move
|
||||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||||
@@ -2317,7 +2333,7 @@ export default function App() {
|
|||||||
{ name: 'help', label: 'Help', items: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
{ 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) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2447,8 +2463,8 @@ export default function App() {
|
|||||||
// handlers across the two layouts.
|
// handlers across the two layouts.
|
||||||
const callsignBlock = (
|
const callsignBlock = (
|
||||||
<div className="flex flex-col w-44">
|
<div className="flex flex-col w-44">
|
||||||
<Label className="mb-1 flex items-center gap-2 h-3.5">
|
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||||
Callsign
|
{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 && <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 && (
|
{!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">
|
<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>
|
</Label>
|
||||||
<div className="relative">
|
<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()) && (
|
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2474,7 +2495,8 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
ref={callsignRef}
|
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}
|
value={callsign}
|
||||||
onChange={(e) => onCallsignInput(e.target.value)}
|
onChange={(e) => onCallsignInput(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -2490,12 +2512,12 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstTxBlock = (
|
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; }} />
|
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstRxBlock = (
|
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; }} />
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2529,7 +2551,7 @@ export default function App() {
|
|||||||
) : null;
|
) : null;
|
||||||
const startBlock = (
|
const startBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<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
|
<Input
|
||||||
readOnly={!locks.start}
|
readOnly={!locks.start}
|
||||||
tabIndex={locks.start ? 0 : -1}
|
tabIndex={locks.start ? 0 : -1}
|
||||||
@@ -2548,7 +2570,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const endBlock = (
|
const endBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<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
|
<Input
|
||||||
readOnly={!locks.end}
|
readOnly={!locks.end}
|
||||||
tabIndex={locks.end ? 0 : -1}
|
tabIndex={locks.end ? 0 : -1}
|
||||||
@@ -2571,7 +2593,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const nameBlock = (
|
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'); }} />
|
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2582,21 +2604,21 @@ export default function App() {
|
|||||||
: (contest.sentFixed || '—');
|
: (contest.sentFixed || '—');
|
||||||
const qthBlock = contest.active ? (
|
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" />
|
<Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
|
||||||
</div>
|
</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"
|
<Input value={rcv} placeholder="exch" className="font-mono"
|
||||||
onChange={(e) => setRcv(e.target.value.toUpperCase())} />
|
onChange={(e) => setRcv(e.target.value.toUpperCase())} />
|
||||||
</div>
|
</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'); }} />
|
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const gridBlock = (
|
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'); }} />
|
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2614,7 +2636,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const commentSm = (
|
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)} />
|
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2622,7 +2644,7 @@ export default function App() {
|
|||||||
// used in the full layout to save vertical height.
|
// used in the full layout to save vertical height.
|
||||||
const bandRow = (
|
const bandRow = (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={band} onValueChange={onBandUserChange}>
|
<Select value={band} onValueChange={onBandUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -2633,7 +2655,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const modeRow = (
|
const modeRow = (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={mode} onValueChange={onModeUserChange}>
|
<Select value={mode} onValueChange={onModeUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -2644,7 +2666,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const countryRow = (
|
const countryRow = (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2653,7 +2675,7 @@ export default function App() {
|
|||||||
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
|
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<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
|
<Input
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
@@ -2667,7 +2689,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const rxFreqBlock = (
|
const rxFreqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<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
|
<Input
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
||||||
@@ -2680,7 +2702,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const bandRxBlock = (
|
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}>
|
<Select value={bandRx} onValueChange={setBandRx}>
|
||||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
<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
|
// Single-line Comment/Note for the full layout (stacked in the right
|
||||||
// column). No flex-1 so they stay one row tall.
|
// column). No flex-1 so they stay one row tall.
|
||||||
const commentLine = (
|
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)} />
|
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const noteLine = (
|
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)} />
|
<Input value={note} onChange={(e) => setNote(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2706,17 +2728,17 @@ export default function App() {
|
|||||||
{clusterServerStatuses.some((s) => s.state === 'connected') && (
|
{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">
|
<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" />
|
<Satellite className="size-3.5" />
|
||||||
Spot
|
{t('btn.spot')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
|
<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)">
|
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
|
||||||
<Eraser className="size-3.5" />
|
<Eraser className="size-3.5" />
|
||||||
Clear
|
{t('btn.clear')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={save} disabled={saving} className="h-8">
|
<Button onClick={save} disabled={saving} className="h-8">
|
||||||
<Send className="size-3.5" />
|
<Send className="size-3.5" />
|
||||||
{saving ? '…' : 'Log QSO'}
|
{saving ? '…' : t('btn.logQso')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2728,7 +2750,7 @@ export default function App() {
|
|||||||
<Label className="mb-1 h-3.5"> </Label>
|
<Label className="mb-1 h-3.5"> </Label>
|
||||||
<Button onClick={save} disabled={saving} className="h-8">
|
<Button onClick={save} disabled={saving} className="h-8">
|
||||||
<Send className="size-3.5" />
|
<Send className="size-3.5" />
|
||||||
{saving ? '…' : 'Log QSO'}
|
{saving ? '…' : t('btn.logQso')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3687,28 +3709,31 @@ export default function App() {
|
|||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{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)]',
|
<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]')}>
|
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">
|
<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">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
||||||
<TabsList className="px-3 shrink-0">
|
<TabsList className="px-3 shrink-0">
|
||||||
<TabsTrigger value="main">Main</TabsTrigger>
|
<TabsTrigger value="main">{t('tab.main')}</TabsTrigger>
|
||||||
<TabsTrigger value="recent">
|
<TabsTrigger value="recent">
|
||||||
Recent QSOs
|
{t('tab.recent')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="cluster">Cluster</TabsTrigger>
|
<TabsTrigger value="cluster">{t('tab.cluster')}</TabsTrigger>
|
||||||
<TabsTrigger value="worked">
|
<TabsTrigger value="worked">
|
||||||
Worked before
|
{t('tab.worked')}
|
||||||
{wb && wb.count > 0 && (
|
{wb && wb.count > 0 && (
|
||||||
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
|
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
<TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger>
|
||||||
<TabsTrigger value="bandmap">Band Map</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 && (
|
{netEnabled && (
|
||||||
<TabsTrigger value="net" className="gap-1.5">
|
<TabsTrigger value="net" className="gap-1.5">
|
||||||
Net
|
{t('tab.net')}
|
||||||
<span
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="Close Net"
|
aria-label="Close Net"
|
||||||
@@ -4031,6 +4056,10 @@ export default function App() {
|
|||||||
<AwardsPanel onEditQSO={openEdit} />
|
<AwardsPanel onEditQSO={openEdit} />
|
||||||
</TabsContent>
|
</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
|
{/* FlexRadio SmartSDR-style control panel — only present when the CAT
|
||||||
backend is a FlexRadio. */}
|
backend is a FlexRadio. */}
|
||||||
{catState.backend === 'flex' && (
|
{catState.backend === 'flex' && (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||||
|
|
||||||
@@ -3758,10 +3759,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GeneralPanel() {
|
function GeneralPanel() {
|
||||||
|
const { lang, setLang, t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
|
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
|
||||||
<div className="space-y-3 max-w-3xl">
|
<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">
|
<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'); }} />
|
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
||||||
Auto-focus "Worked before" for known stations
|
Auto-focus "Worked before" for known stations
|
||||||
|
|||||||
@@ -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,6 +3,7 @@ import {createRoot} from 'react-dom/client'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { syncPortablePrefs } from './lib/uiPref'
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
|
import { I18nProvider } from './lib/i18n'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
@@ -14,7 +15,9 @@ const root = createRoot(container!)
|
|||||||
syncPortablePrefs().finally(() => {
|
syncPortablePrefs().finally(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<I18nProvider>
|
||||||
<App/>
|
<App/>
|
||||||
|
</I18nProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+4
@@ -68,6 +68,10 @@ export function ConnectAllClusters():Promise<void>;
|
|||||||
|
|
||||||
export function ConnectClusterServer(arg1:number):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 CountQSO():Promise<number>;
|
||||||
|
|
||||||
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
|
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ export function ConnectClusterServer(arg1) {
|
|||||||
return window['go']['main']['App']['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() {
|
export function CountQSO() {
|
||||||
return window['go']['main']['App']['CountQSO']();
|
return window['go']['main']['App']['CountQSO']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1376,6 +1376,60 @@ export namespace main {
|
|||||||
this.count = source["count"];
|
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 {
|
export class CtyDatInfo {
|
||||||
path: string;
|
path: string;
|
||||||
entities: number;
|
entities: number;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package award
|
|||||||
|
|
||||||
import "strings"
|
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
|
// 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:
|
// 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
|
// - standard call: letters+digits up to and including the LAST digit of the
|
||||||
|
|||||||
Reference in New Issue
Block a user