Compare commits
209
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f853dd479e | ||
|
|
829c236d6c | ||
|
|
c9fd1379e1 | ||
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 | ||
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 | ||
|
|
f5ffe81c72 | ||
|
|
656e238a59 | ||
|
|
c170d6091e | ||
|
|
ae60d58893 | ||
|
|
68982e9a85 | ||
|
|
eb9e2db41a | ||
|
|
b59c6856bd | ||
|
|
a00817b93e | ||
|
|
7398261c50 | ||
|
|
73f3ec51f7 | ||
|
|
6c39204301 | ||
|
|
0c3089344b | ||
|
|
6ab606fa54 | ||
|
|
96390110f8 | ||
|
|
6f2f9236b0 | ||
|
|
efb61107fe | ||
|
|
16e780c2df | ||
|
|
f3bf0b2f5c | ||
|
|
1f74e4d234 | ||
|
|
19c2de5f61 | ||
|
|
5ae2bad549 | ||
|
|
e487aa78f3 | ||
|
|
b9079fe4e2 | ||
|
|
206a6bff09 | ||
|
|
6ee31ae45d | ||
|
|
521f8266cf | ||
|
|
1f5e5759cc | ||
|
|
f665d666d6 | ||
|
|
5f9ee9efa2 | ||
|
|
44d276c841 | ||
|
|
e656c79589 | ||
|
|
86cbe94cae | ||
|
|
b9b005ea36 | ||
|
|
2c47c5d052 | ||
|
|
446888a330 | ||
|
|
128364260b | ||
|
|
daf38554a5 | ||
|
|
39f095ae75 | ||
|
|
ea8e954eef | ||
|
|
6db90abcad | ||
|
|
6b9307b427 | ||
|
|
ab06673854 | ||
|
|
1cadefd207 | ||
|
|
05fbb8a3bc | ||
|
|
3d88d8f50f | ||
|
|
5b37397a64 | ||
|
|
1184a675c2 | ||
|
|
7a24282aa4 | ||
|
|
701e8a2c25 | ||
|
|
c4ab935d5f | ||
|
|
be00f6a46d | ||
|
|
7b0a1ac832 | ||
|
|
06183bd5d4 | ||
|
|
fafa0c22ab | ||
|
|
4f32012930 | ||
|
|
a8b3269b1e | ||
|
|
25a53e4110 | ||
|
|
8cf53a0aa2 | ||
|
|
e112de5967 | ||
|
|
be48e58aa6 | ||
|
|
565c143536 | ||
|
|
2ce25339ab | ||
|
|
121ecae314 | ||
|
|
dd0a34dc0a | ||
|
|
45b9bcdea7 | ||
|
|
2d742be7df | ||
|
|
3a6afc28ac | ||
|
|
e590a58702 | ||
|
|
dd4b0004a5 | ||
|
|
abbdde9367 | ||
|
|
fda21e5a6f | ||
|
|
ee9fb51066 | ||
|
|
5bbcaab71a | ||
|
|
18438d7737 | ||
|
|
97e24ea24f | ||
|
|
3e199f9ab6 | ||
|
|
8740a4ba66 | ||
|
|
8ccad7ca65 | ||
|
|
fa7df57435 | ||
|
|
812e4f05e5 | ||
|
|
6ec31b61ce | ||
|
|
93c8f6b9d3 | ||
|
|
65c22232dd | ||
|
|
edede0bc1e | ||
|
|
2712902057 | ||
|
|
9281645359 | ||
|
|
a05dd6b3a9 | ||
|
|
a2401d7fd3 | ||
|
|
053b351dab | ||
|
|
64f2d38d87 | ||
|
|
299184712a | ||
|
|
76c1e2df60 | ||
|
|
165f33caa5 | ||
|
|
464a1c702c | ||
|
|
19c5045dc6 | ||
|
|
fa09251039 | ||
|
|
d6626d96d0 | ||
|
|
8b831145ad | ||
|
|
81c60628c6 | ||
|
|
678787ec62 | ||
|
|
79dc20a859 | ||
|
|
824971d0a1 | ||
|
|
60bcd2422d | ||
|
|
9b0d7ce1dc | ||
|
|
572e8ca538 | ||
|
|
6ac9783f7c | ||
|
|
725600c341 | ||
|
|
5d9765be09 | ||
|
|
b302d4d87b | ||
|
|
8b7c42ec9b | ||
|
|
cde0add5e0 | ||
|
|
0e2ef317c3 | ||
|
|
a9f2e515e1 | ||
|
|
260172cd6d | ||
|
|
4b5e2e0b72 | ||
|
|
4a6ea45665 | ||
|
|
95d37da3bb | ||
|
|
e1b3f0faf3 | ||
|
|
6379e2cd1f | ||
|
|
2228816057 | ||
|
|
32878c17be | ||
|
|
079d0c32df | ||
|
|
45d081ac0c | ||
|
|
183db7ac2b | ||
|
|
4d074de27e | ||
|
|
679e8f8d39 | ||
|
|
dd2deee939 | ||
|
|
cdd71b17c8 | ||
|
|
e8eedcc1dc | ||
|
|
3c47366f56 | ||
|
|
bd11bb4763 | ||
|
|
40e95e6a16 | ||
|
|
cc0f9ffc64 | ||
|
|
e1f1ab4922 | ||
|
|
b6d991b799 | ||
|
|
59f1775fcd | ||
|
|
b2a8b1946f | ||
|
|
8b1609f5ce | ||
|
|
bde1195b34 | ||
|
|
abdab22010 | ||
|
|
16dc864dbd | ||
|
|
01235624ee | ||
|
|
a7bbc53c35 | ||
|
|
3d15f20c7f | ||
|
|
e5c6bddb29 | ||
|
|
75ee5344a4 | ||
|
|
957182611d | ||
|
|
69d0780bac | ||
|
|
33af122964 | ||
|
|
22e3bb4a18 | ||
|
|
29fd832bcd | ||
|
|
67203cd4a8 | ||
|
|
08162fa126 | ||
|
|
81e505e040 | ||
|
|
0b3e22c97e | ||
|
|
d3ba7c71f4 | ||
|
|
00cab6b204 | ||
|
|
ff53831be4 | ||
|
|
3cb2e466d8 | ||
|
|
408b29896c | ||
|
|
6150498a9e | ||
|
|
42b5c6247d | ||
|
|
6542504a4b | ||
|
|
3dd9620cca | ||
|
|
7d80d26bbd | ||
|
|
9189f54df5 | ||
|
|
eb64b8f2f9 | ||
|
|
d0526c39f5 | ||
|
|
8040a37315 | ||
|
|
16c04fc12b | ||
|
|
17f7a00bd7 | ||
|
|
f91f9ff3b8 | ||
|
|
176cc0e62b | ||
|
|
b4e104f5a2 | ||
|
|
922a185208 | ||
|
|
51d3a734e8 | ||
|
|
88623f55df | ||
|
|
cf9dbf26f3 | ||
|
|
95fdc1ccd1 | ||
|
|
a2a29c66d2 | ||
|
|
1a425a1b0d | ||
|
|
2b4326b553 | ||
|
|
2eb77370e4 | ||
|
|
806b39970b | ||
|
|
8f1ad126ac |
@@ -0,0 +1 @@
|
||||
{"sessionId":"9e3eadf5-7e35-4848-8cf9-515589d63e73","pid":5360,"acquiredAt":1780094656528}
|
||||
+19
-1
@@ -4,7 +4,25 @@
|
||||
"Bash(go get *)",
|
||||
"Bash(go build *)",
|
||||
"Bash(wails generate *)",
|
||||
"Bash(npm run *)"
|
||||
"Bash(npm run *)",
|
||||
"Bash(gofmt -w app.go internal/winkeyer/winkeyer.go)",
|
||||
"Bash(timeout 20 git status --short)",
|
||||
"Bash(echo \"exit: $?\")",
|
||||
"Bash(GIT_TERMINAL_PROMPT=0 timeout 20 git push --dry-run)",
|
||||
"Bash(echo \"push exit: $?\")",
|
||||
"Bash(git config *)",
|
||||
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
|
||||
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
|
||||
"Bash(which git-credential-manager *)",
|
||||
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
|
||||
"Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)",
|
||||
"Read(//c/Perso/Seafile/Programmation/Golang/**)",
|
||||
"Bash(gofmt -w internal/qslcard/*.go)",
|
||||
"Bash(awk '{print $3}')",
|
||||
"Bash(xargs grep -n \"CREATE TABLE\\\\|key\\\\|value\")",
|
||||
"Bash(ls -la .claude/)",
|
||||
"Bash(cat .claude/settings.local.json)",
|
||||
"Bash(cat .claude/settings.json)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go:*)",
|
||||
"Bash(gofmt:*)",
|
||||
"Bash(/c/Users/legre/go/bin/wails:*)",
|
||||
"Bash(wails:*)",
|
||||
"Bash(npm:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(awk:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(sort:*)",
|
||||
"Bash(uniq:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(for f in *)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(curl -sI --max-time 10 https://raw.githubusercontent.com/google/fonts/main/ofl/archivoblack/ArchivoBlack-Regular.ttf)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ desktop.ini
|
||||
hamlog.db*
|
||||
cty.dat
|
||||
cat.log
|
||||
*.adi
|
||||
|
||||
# --- Secrets ---
|
||||
.env
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# OpsLog
|
||||
|
||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
|
||||
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
||||
concepteur de cartes QSL. Construit avec **Wails v2** (backend Go + frontend
|
||||
React/TypeScript), **100 % Go** (sans CGO) : SQLite pour la configuration,
|
||||
**MySQL partagé** optionnel pour le journal afin que plusieurs opérateurs
|
||||
partagent un même log. Entièrement thémable et bilingue (anglais / français).
|
||||
|
||||
Développé par **F4BPO**.
|
||||
|
||||
---
|
||||
|
||||
## Compilation / développement
|
||||
|
||||
- **Dev :** `wails dev` (rechargement à chaud Vite ; méthodes Go accessibles sur http://localhost:34115).
|
||||
- **Build :** `wails build` (utiliser la version wails v2.11 du projet — `~/go/bin/wails.exe`).
|
||||
- **Régénérer les bindings Go↔TS** après modification des méthodes `App`
|
||||
exportées : `wails generate module`.
|
||||
- **Release :** `.vscode/release.ps1` (Ctrl+Maj+P → *Tasks: Run Task* →
|
||||
*Release OpsLog*) — incrémente la version, pousse le source sur Gitea, compile
|
||||
l'exe et le publie sur les releases Gitea + GitHub.
|
||||
|
||||
---
|
||||
|
||||
## Journalisation
|
||||
|
||||
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
|
||||
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
|
||||
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
|
||||
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
||||
nom/QTH/locator et onglet QRZ.com.
|
||||
- **Résolution DXCC hors ligne** depuis `cty.dat` (pays, zones CQ/ITU,
|
||||
continent), avec gestion des `/MM` `/AM` `/B` (balise) et des changements de
|
||||
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
||||
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
||||
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||
QSL.
|
||||
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||
sélectionnées.
|
||||
- **Recherche de doublons** (Outils) — regroupe les QSO par même indicatif +
|
||||
bande + mode (optionnellement même jour / minute) et permet de choisir lesquels
|
||||
supprimer.
|
||||
- **Conforme ADIF 3.1.7** en import/export : dictionnaire complet des champs,
|
||||
30 colonnes promues, éditeur générique de « champs supplémentaires » et modes
|
||||
d'export standard/complet.
|
||||
- **Profils :** chaque réglage est par profil ; chaque profil peut pointer son
|
||||
journal vers le fichier SQLite local ou une base **MySQL partagée**
|
||||
(multi-opérateur).
|
||||
|
||||
## Cartes & antenne
|
||||
|
||||
- **Vue principale = deux volets configurables** (par profil, Réglages → Général
|
||||
→ *Vue principale*) : carte grand-cercle, carte locator (rue), la grille du
|
||||
cluster, la grille des déjà-contactés, les QSO récents, les **commandes
|
||||
FlexRadio**, la **console Icom** ou le panneau **Net control**.
|
||||
- **Carte grand-cercle** avec distance & azimut trajet court/long, fonds de carte
|
||||
sélectionnables (Light / Voyager / Street / Satellite, tous sans clé et
|
||||
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||
rotor.
|
||||
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||
PstRotator.
|
||||
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
||||
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
||||
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||
|
||||
## Cluster DX
|
||||
|
||||
- Plusieurs serveurs de cluster avec reconnexion auto, un maître pour les
|
||||
commandes.
|
||||
- **Barre latérale de filtres** (recherche d'indicatif, masquer-déjà-contactés,
|
||||
grouper les doublons, bande / mode / statut / source) partagée entre l'onglet
|
||||
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||
panadapter).
|
||||
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||
`api.pota.app`).
|
||||
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
||||
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
||||
*Gestion des alertes*).
|
||||
|
||||
## Contrôle CAT
|
||||
|
||||
Quatre backends natifs (Réglages → CAT), chacun avec reconnexion auto et une
|
||||
connexion rapide non bloquante — une radio éteinte ne fige jamais l'application :
|
||||
|
||||
- **OmniRig** (Rig 1/2, changement à chaud) — fonctionne avec toute radio
|
||||
supportée par OmniRig.
|
||||
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
||||
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
||||
du cluster poussés sur l'écran Flex, clic → remplir l'indicatif).
|
||||
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
||||
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
||||
RS-BA1 ni Remote Utility nécessaires.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 et tout serveur compatible TCI :
|
||||
fréquence / mode / PTT / split, plus spots panorama optionnels.
|
||||
|
||||
Le mode est lu depuis la radio ; le sous-mode numérique (FT4 vs FT8) est déduit
|
||||
de la fréquence. Les **antennes RX/TX Flex par bande** sont configurables et
|
||||
appliquées automatiquement au changement de bande.
|
||||
|
||||
### Onglet de commande FlexRadio (façon SmartSDR)
|
||||
|
||||
Affiché uniquement quand le backend CAT est une FlexRadio :
|
||||
|
||||
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
||||
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
||||
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||
|
||||
### Onglet de commande Icom
|
||||
|
||||
Affiché quand le backend CAT est Icom (USB ou réseau). Une console complète façon
|
||||
RS-BA1 :
|
||||
|
||||
- **Double afficheur VFO** (MAIN / SUB) avec la grande fréquence tabulaire, le
|
||||
badge de mode, la bande et l'offset RIT/ΔTX, et une **rangée de boutons de
|
||||
mode** (SSB / CW / RTTY / PSK / AM / FM).
|
||||
- **Scope de spectre + waterfall** (panadapter) : ON/OFF, CTR/FIX, double-clic
|
||||
pour accorder, et boutons **◀ ⊙ ▶** pour centrer le scope sur la fréquence
|
||||
actuelle (±50 kHz) et le décaler à gauche/droite.
|
||||
- **Mesures en direct** toujours visibles : S-mètre (clic → remplir le RST),
|
||||
puissance en watts, ROS.
|
||||
- **DSP réception :** gain AF / RF, squelch, AGC, préampli, atténuateur, filtre
|
||||
(FIL1/2/3), NB, NR, ANF et — **en CW seulement** — l'**APF** (filtre de pic
|
||||
audio).
|
||||
- **Passe-bande / notch :** Twin PBT (intérieur / extérieur), notch manuel +
|
||||
position.
|
||||
- **Émission :** puissance RF, MOX, TUNE, **split avec offset automatique**
|
||||
(+5 kHz en SSB, +1 kHz en CW), et moniteur. **En phonie seulement** : gain
|
||||
micro, processeur de parole, VOX (+ gain + anti-VOX). Les commandes qui ne
|
||||
s'appliquent pas au mode courant sont masquées automatiquement.
|
||||
- **Bandes & antenne :** boutons de bande en un clic et sélection ANT1/ANT2.
|
||||
- **Clarificateurs :** RIT et ΔTX avec accord molette / ± (Ctrl+←/→ décale le
|
||||
RIT).
|
||||
- Boutons **Marche / Arrêt** (manuels par choix — l'application ne réveille
|
||||
jamais la radio à la connexion).
|
||||
- La **manipulation CW** peut passer par le keyer intégré de la radio (voir
|
||||
*Keyers* ci-dessous).
|
||||
|
||||
### Icom à distance (par internet, sans RS-BA1)
|
||||
|
||||
OpsLog parle directement le protocole réseau intégré de l'IC-7610 — il
|
||||
**remplace à la fois l'Icom Remote Utility et RS-BA1**. Saisissez l'IP de la
|
||||
radio, le nom/mot de passe Network User1 et l'adresse CI-V, et toute la console
|
||||
Icom fonctionne sur le LAN/internet : login + token (renouvelé automatiquement),
|
||||
tunnel CI-V, retransmission côté réception pour une liaison très stable même
|
||||
avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmètre
|
||||
— utilisez la radio en USB + une liaison voix comme Mumble.)
|
||||
|
||||
## Keyers & audio
|
||||
|
||||
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
||||
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
||||
distance) ou **TCI**.
|
||||
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||
émettre.
|
||||
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||
*Log QSO* le contact est sauvegardé dans un WAV par QSO
|
||||
(`INDICATIF_AAAAMMJJ_HHMMSS.wav`) ; mixe RX + micro.
|
||||
|
||||
## Amplis & commutateurs
|
||||
|
||||
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
||||
sélecteur de mode ventilateur et affichage des défauts.
|
||||
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||
commutation A/B ancré.
|
||||
|
||||
## QSL & diplômes
|
||||
|
||||
- **Moteur de diplômes :** définitions intégrées + personnalisées (partagées
|
||||
**globalement** entre profils) — DXCC, WAS / WAZ / WAC, WPX,
|
||||
IOTA / POTA / SOTA / WWFF, **DDFM**, contacté/confirmé/validé par bande & mode,
|
||||
règles OU et affectation manuelle de références, détection de référence en
|
||||
direct à la saisie de l'indicatif, **import de listes de références** pour les
|
||||
totaux/noms, et un **Rescan** qui relit le journal (récupère les nouvelles
|
||||
confirmations LoTW/QRZ).
|
||||
- **Services QSL :** ClubLog (upload ADIF par lots), LoTW, QRZ.com, eQSL —
|
||||
upload et **téléchargement des confirmations** (qui rafraîchit automatiquement
|
||||
les stats de diplômes).
|
||||
- **Concepteur de cartes QSL** (voir ci-dessous).
|
||||
- **eQSL par e-mail :** clic droit sur un QSO → *Envoyer l'eQSL par e-mail* via
|
||||
le compte SMTP configuré. (Outlook/Hotmail désactivent le SMTP basic-auth —
|
||||
utilisez Gmail avec un mot de passe d'application, ou un mot de passe
|
||||
d'application Microsoft.)
|
||||
|
||||
## Log de concours
|
||||
|
||||
- **Onglet Contest :** choisissez un concours (liste ADIF `CONTEST_ID` intégrée)
|
||||
et un échange (numéro de série courant ou échange fixe). OpsLog remplit
|
||||
automatiquement `CONTEST_ID` et les numéros émis/reçus (`STX` / `SRX`), impose
|
||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||
direct.
|
||||
|
||||
## Statut opérateur en direct (événements spéciaux)
|
||||
|
||||
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*.
|
||||
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
||||
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
||||
~15 s. Un petit rendu PHP
|
||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
||||
propre serveur web lit cette table et produit une page/image en direct que vous
|
||||
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||
(`<img src="…/tm74-status.php?img=1">`). OpsLog écrit seulement dans la base —
|
||||
ce n'est pas un serveur web.
|
||||
|
||||
## Net control
|
||||
|
||||
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
||||
session active en mémoire — pointez les stations présentes, puis loguez tout le
|
||||
net d'un coup en utilisant la fréquence CAT.
|
||||
|
||||
## Apparence & langue
|
||||
|
||||
- **Thèmes :** quatre thèmes complets (Clair chaud, Sombre chaud, Graphite
|
||||
sombre, Contraste élevé) plus **Auto** (suit la préférence clair/sombre du
|
||||
système), sélectionnables dans Réglages → Général. Chaque panneau et chaque
|
||||
table AG-Grid suit le thème.
|
||||
- **Bilingue :** interface complète **anglais / français**, avec un choix de
|
||||
drapeau au premier lancement et un sélecteur dans Réglages → Général.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Coffre à secrets :** chiffrement optionnel par phrase de passe des mots de
|
||||
passe stockés (AES-GCM + PBKDF2). Les valeurs chiffrées sont portables ; une
|
||||
seule invite de déverrouillage au lancement les déchiffre pour la session.
|
||||
|
||||
## Intégrations (sortantes)
|
||||
|
||||
- **Émetteurs UDP :** pousser la fréquence actuelle vers **PstRotator**, les
|
||||
infos radio au format **N1MM `RadioInfo`**, ou un **enregistrement ADIF à
|
||||
chaque QSO logué** — pour que les outils externes (contrôle de rotor,
|
||||
applications numériques, autres logiciels de log) restent synchronisés.
|
||||
|
||||
## Divers
|
||||
|
||||
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
||||
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||
désactivable dans les Préférences).
|
||||
|
||||
---
|
||||
|
||||
## Concepteur de cartes QSL
|
||||
|
||||
Outils → *Concepteur de cartes QSL…* transforme quelques photos en une carte
|
||||
eQSL soignée :
|
||||
|
||||
1. Choisissez 1 à 6 photos (jpeg/png). OpsLog les analyse hors ligne (grille de
|
||||
détail/luminance) et propose **3 designs** — indicatif dans la zone la plus
|
||||
calme de la meilleure photo, nom de l'opérateur, zones CQ/ITU + ligne locator,
|
||||
drapeau du pays, les autres photos en inserts encadrés, et un encart de
|
||||
confirmation par QSO.
|
||||
2. Choisissez une proposition et affinez-la : cliquez un élément pour le
|
||||
sélectionner, glissez pour déplacer, changez la police / le préréglage de
|
||||
style (gel or, gel argent, contour blanc classique, script, plat) et les
|
||||
réglages par préréglage dans le panneau de droite.
|
||||
3. Enregistrez le modèle (les photos sont copiées dans
|
||||
`data/qsl/templates/<id>/`, les originaux peuvent donc être déplacés). Un
|
||||
modèle peut être le défaut par profil.
|
||||
|
||||
Envoi : clic droit sur un QSO → *Envoyer l'eQSL par e-mail*. La carte est rendue
|
||||
avec les données de ce QSO, matricée en JPEG ≤ 800 Ko, archivée dans
|
||||
`data/qsl/outbox/` et envoyée via le compte SMTP configuré à l'adresse trouvée
|
||||
par la recherche QRZ/HamQTH. En cas de succès le QSO est marqué `EQSL_SENT=Y`
|
||||
(ADIF). Les modèles de sujet/corps de l'e-mail sont dans le concepteur (variables
|
||||
`{CALL} {DATE} {BAND} {MODE} {MYCALL}`).
|
||||
|
||||
Polices : Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura
|
||||
(toutes OFL, embarquées — licences dans `internal/qslcard/assets/fonts/`) ;
|
||||
Cooper Black est proposée si MS Office l'a installée. Drapeaux : flag-icons
|
||||
(MIT), embarqués pour les entités DXCC couramment contactées.
|
||||
|
||||
---
|
||||
|
||||
## Données & stockage
|
||||
|
||||
- La **config** (réglages, profils, radios/antennes, nœuds de cluster, cache de
|
||||
recherche, listes de diplômes, modèles QSL) réside toujours dans le fichier
|
||||
SQLite local sous `data/` — instantané même quand le journal est sur un MySQL
|
||||
lointain.
|
||||
- Le **journal** (QSO) réside là où pointe le profil actif : le fichier SQLite
|
||||
local ou une base **MySQL** partagée par profil.
|
||||
|
||||
---
|
||||
|
||||
*An English version of this document is available in [README.md](README.md).*
|
||||
@@ -1,16 +1,265 @@
|
||||
# README
|
||||
# OpsLog
|
||||
|
||||
## About
|
||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||
management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||
Fully themeable and bilingual (English / French).
|
||||
|
||||
This is the official Wails Svelte-TS template.
|
||||
Developed by **F4BPO**.
|
||||
|
||||
## Live Development
|
||||
---
|
||||
|
||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
||||
to this in your browser, and you can call your Go code from devtools.
|
||||
## Building / developing
|
||||
|
||||
## Building
|
||||
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
|
||||
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
|
||||
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
|
||||
`wails generate module`.
|
||||
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* →
|
||||
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
|
||||
and publishes it to Gitea + GitHub releases.
|
||||
|
||||
To build a redistributable, production mode package, use `wails build`.
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||
**flag** is shown large next to the RST fields.
|
||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||
and the QRZ.com tab.
|
||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||
ClubLog DXpedition date overrides.
|
||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
||||
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||
presets) with filtered- and selected-row **ADIF export**.
|
||||
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||
same day / minute) and lets you pick which to delete.
|
||||
- **ADIF 3.1.7 compliant** import/export: a full field dictionary, 30 promoted
|
||||
columns, a generic "extra fields" editor and standard/all export modes.
|
||||
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
||||
|
||||
## Maps & antenna
|
||||
|
||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||
worked-before grid, recent QSOs, the **FlexRadio controls**, the **Icom
|
||||
console** or the **Net control** panel.
|
||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
||||
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||
direction is shown in green and the **mechanical boom** in grey, on both the
|
||||
compass and the map, so you never lose track of where the antenna points.
|
||||
|
||||
## DX Cluster
|
||||
|
||||
- Multiple cluster servers with auto-reconnect, a master for commands.
|
||||
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
|
||||
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||
pane, with a show/hide toggle.
|
||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
||||
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
||||
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||
management*).
|
||||
|
||||
## CAT control
|
||||
|
||||
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app:
|
||||
|
||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||
the Flex display, click → fill the call).
|
||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||
Remote Utility needed.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, plus optional panorama spots.
|
||||
|
||||
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||
automatically on band change.
|
||||
|
||||
### FlexRadio control tab (SmartSDR-style)
|
||||
|
||||
Shown only when the CAT backend is a FlexRadio:
|
||||
|
||||
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||
|
||||
### Icom control tab
|
||||
|
||||
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
||||
|
||||
- **Twin VFO readout** (MAIN / SUB) with the big tabular frequency, mode badge,
|
||||
band and RIT/ΔTX offset, and a **mode-button row** (SSB / CW / RTTY / PSK /
|
||||
AM / FM).
|
||||
- **Spectrum scope + waterfall** (panadapter): ON/OFF, CTR/FIX, double-click to
|
||||
tune, and **◀ ⊙ ▶** buttons to centre the scope on the current frequency
|
||||
(±50 kHz) and pan left/right.
|
||||
- **Live meters** always visible: S-meter (click → fill RST), power in watts, SWR.
|
||||
- **Receive DSP:** AF / RF gain, squelch, AGC, preamp, attenuator, filter
|
||||
(FIL1/2/3), NB, NR, ANF and — **on CW only** — the **APF** (audio peak filter).
|
||||
- **Passband / notch:** Twin PBT (inner / outer), manual notch + position.
|
||||
- **Transmit:** RF power, MOX, TUNE, **split with an automatic offset**
|
||||
(+5 kHz on SSB, +1 kHz on CW), and monitor. On **voice modes only**: mic gain,
|
||||
speech compressor, VOX (+ gain + anti-VOX). Controls that don't apply to the
|
||||
current mode are hidden automatically.
|
||||
- **Bands & antenna:** one-touch band buttons and ANT1/ANT2 selection.
|
||||
- **Clarifiers:** RIT and ΔTX with wheel / ± tuning (Ctrl+←/→ nudges RIT).
|
||||
- **Power ON / OFF** buttons (manual by design — the app never wakes the rig on
|
||||
connect).
|
||||
- **CW keying** can run through the radio's own keyer (see *Keyers* below).
|
||||
|
||||
### Remote Icom (over the internet, no RS-BA1)
|
||||
|
||||
OpsLog speaks the IC-7610's built-in network protocol directly — it **replaces
|
||||
both the Icom Remote Utility and RS-BA1**. Enter the radio's IP, the Network
|
||||
User1 name/password and the CI-V address, and the whole Icom console works over
|
||||
the LAN/internet: login + token (auto-renewed), CI-V tunnel, receive-side
|
||||
retransmit for a rock-solid link even with the panadapter streaming, and manual
|
||||
power ON/OFF. (Audio is out of scope — use the radio in USB + a voice link such
|
||||
as Mumble.)
|
||||
|
||||
## Keyers & audio
|
||||
|
||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
||||
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||
|
||||
## Amplifiers & switches
|
||||
|
||||
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
||||
selector and fault display.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
|
||||
## QSL & awards
|
||||
|
||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||
across profiles) — DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF,
|
||||
**DDFM**, worked/confirmed/validated by band & mode, OR rules and manual
|
||||
reference assignment, live reference detection on call entry, **reference-list
|
||||
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||
fresh LoTW/QRZ confirmations).
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||
and **confirmation download** (which auto-refreshes the award stats).
|
||||
- **QSL Card Designer** (see below).
|
||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||
password, or a Microsoft app password.)
|
||||
|
||||
## Contest logging
|
||||
|
||||
- **Contest tab:** pick a contest (built-in ADIF `CONTEST_ID` list) and an
|
||||
exchange (running serial or a fixed exchange). OpsLog auto-fills `CONTEST_ID`
|
||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||
flags dupes and keeps a live scoreboard.
|
||||
|
||||
## Multi-operator live status (special events)
|
||||
|
||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
||||
Settings → General → *Publish live operator status*. Each OpsLog instance
|
||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||
`live_status` table every ~15 s. A small PHP renderer
|
||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
||||
own web server reads that table and produces a live page/image you can embed on
|
||||
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||
only writes to the DB — it is not a web server.
|
||||
|
||||
## Net control
|
||||
|
||||
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
||||
in-memory active session — check stations in, then log the whole net at once
|
||||
using the CAT frequency.
|
||||
|
||||
## Appearance & language
|
||||
|
||||
- **Themes:** four complete themes (Warm light, Warm dark, Graphite dark, High
|
||||
contrast) plus **Auto** (follows the OS light/dark preference), selectable in
|
||||
Settings → General. Every panel and every AG-Grid table follows the theme.
|
||||
- **Bilingual:** full **English / French** UI, with a first-run flag chooser and
|
||||
a switcher in Settings → General.
|
||||
|
||||
## Security
|
||||
|
||||
- **Secret vault:** opt-in passphrase encryption of the stored passwords
|
||||
(AES-GCM + PBKDF2). Encrypted values are portable; a single unlock prompt at
|
||||
launch decrypts them for the session.
|
||||
|
||||
## Integrations (outbound)
|
||||
|
||||
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||
external tools (rotator control, digital apps, other loggers) stay in sync.
|
||||
|
||||
## Other
|
||||
|
||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||
OpsLog startup, skipping any already running.
|
||||
- **Backup:** optional database + ADIF backup at shutdown.
|
||||
- **Update check** at startup with a toast (toggleable).
|
||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||
|
||||
---
|
||||
|
||||
## QSL Card Designer
|
||||
|
||||
Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card:
|
||||
|
||||
1. Pick 1–6 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance
|
||||
grid) and proposes **3 designs** — callsign in the calmest zone of the best
|
||||
photo, operator name, CQ/ITU zones + locator line, country flag, the other
|
||||
photos as bordered inserts, and a per-QSO confirmation box.
|
||||
2. Pick a proposal and fine-tune it: click an element to select, drag to move,
|
||||
change font / style preset (gel gold, gel silver, classic white outline,
|
||||
script, flat) and per-preset knobs in the right panel.
|
||||
3. Save the template (photos are copied into `data/qsl/templates/<id>/`, so the
|
||||
originals can move). One template can be the default per profile.
|
||||
|
||||
Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with
|
||||
that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`
|
||||
and sent through the configured SMTP account to the address found by the
|
||||
QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The
|
||||
e-mail subject/body templates live in the designer
|
||||
(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables).
|
||||
|
||||
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
|
||||
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||
commonly-worked DXCC entities.
|
||||
|
||||
---
|
||||
|
||||
## Data & storage
|
||||
|
||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||
award lists, QSL templates) always lives in the local SQLite file under
|
||||
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||
file or a per-profile shared **MySQL** database.
|
||||
|
||||
---
|
||||
|
||||
*A French version of this document is available in [README.fr.md](README.fr.md).*
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cwdecode"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||
// only by the frontend, and only while the entry mode is CW.
|
||||
//
|
||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||
// back to auto-search otherwise.
|
||||
|
||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||
func (a *App) cwTargetPitch() int {
|
||||
if a.cwPitchHz > 0 {
|
||||
return a.cwPitchHz
|
||||
}
|
||||
if a.cat != nil {
|
||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||
// Only trust the radio's pitch when it's actually in CW.
|
||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||
if st.CWPitch > 0 {
|
||||
return st.CWPitch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||
// call repeatedly; a second call is a no-op while already running.
|
||||
func (a *App) StartCWDecoder() error {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
if a.cwStop != nil {
|
||||
return nil // already running
|
||||
}
|
||||
dev := ""
|
||||
if a.settings != nil {
|
||||
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
||||
}
|
||||
if dev == "" {
|
||||
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
||||
}
|
||||
|
||||
dec := cwdecode.New(audio.SampleRate,
|
||||
func(text string) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
||||
}
|
||||
},
|
||||
func(st cwdecode.Status) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
||||
}
|
||||
},
|
||||
)
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
a.cwDecoder = dec
|
||||
|
||||
stop := make(chan struct{})
|
||||
a.cwStop = stop
|
||||
go func() {
|
||||
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
||||
applog.Printf("cw: capture failed: %v", err)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||
}
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
if a.cwStop == stop {
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
}
|
||||
a.cwMu.Unlock()
|
||||
}()
|
||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||
go a.cwFollowPitch(stop, dec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopCWDecoder halts the CW decoder if running.
|
||||
func (a *App) StopCWDecoder() {
|
||||
a.cwMu.Lock()
|
||||
stop := a.cwStop
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
a.cwMu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
}
|
||||
|
||||
// CWDecoderRunning reports whether the decoder is currently capturing.
|
||||
func (a *App) CWDecoderRunning() bool {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwStop != nil
|
||||
}
|
||||
|
||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||
func (a *App) SetCWDecoderPitch(hz int) {
|
||||
if hz < 0 {
|
||||
hz = 0
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
a.cwPitchHz = hz
|
||||
dec := a.cwDecoder
|
||||
a.cwMu.Unlock()
|
||||
if dec != nil {
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
|
||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||
func (a *App) GetCWDecoderPitch() int {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwPitchHz
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
package main
|
||||
|
||||
// QSL card designer bindings. All logic lives in internal/qslcard — this file
|
||||
// only adapts it to the Wails boundary (thin methods on *App, same receiver
|
||||
// as app.go so bindings keep working). Template documents cross the bridge
|
||||
// as JSON strings: the schema is the single source of truth and the frontend
|
||||
// mirrors it in qslTypes.ts.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/email"
|
||||
"hamlog/internal/qslcard"
|
||||
"hamlog/internal/qso"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// Settings keys for the eQSL e-mail templates (subject/body share the
|
||||
// {CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} variables of the recording e-mail).
|
||||
const (
|
||||
keyQSLEmailSubject = "qsl.email_subject"
|
||||
keyQSLEmailBody = "qsl.email_body"
|
||||
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||
)
|
||||
|
||||
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
|
||||
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
|
||||
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
||||
|
||||
const (
|
||||
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||||
)
|
||||
|
||||
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
||||
func (a *App) qslDir() string { return filepath.Join(a.dataDir, "qsl") }
|
||||
|
||||
// QSLTemplateInfo is the template-list row shown in the designer.
|
||||
type QSLTemplateInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProfileID *int64 `json:"profile_id,omitempty"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// QSLFontInfo is one font face offered by the designer, TTF bytes included
|
||||
// so the frontend can build @font-face rules (and inline them into the SVG
|
||||
// before rasterization).
|
||||
type QSLFontInfo struct {
|
||||
Family string `json:"family"`
|
||||
Kind string `json:"kind"` // "display" | "script" | "system"
|
||||
Variable bool `json:"variable"`
|
||||
DataB64 string `json:"data_b64"`
|
||||
}
|
||||
|
||||
// QSLPresetInfo describes one style preset for the picker.
|
||||
type QSLPresetInfo struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Params []string `json:"params"` // accepted style_params keys
|
||||
Defaults qslcard.StyleParams `json:"defaults"`
|
||||
}
|
||||
|
||||
// QSLPickPhotos opens a multi-select dialog for the designer drop zone's
|
||||
// "browse" path (drag & drop hands the webview File objects without paths,
|
||||
// so picking through the native dialog is the reliable route to real paths).
|
||||
func (a *App) QSLPickPhotos() ([]string, error) {
|
||||
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose card photos (1–5)",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// QSLGenerateProposals analyzes the photos and returns 3 distinct template
|
||||
// documents (JSON) from the automatic placement engine.
|
||||
func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
||||
if len(photoPaths) == 0 {
|
||||
return nil, fmt.Errorf("no photos selected")
|
||||
}
|
||||
if len(photoPaths) > 5 {
|
||||
return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
|
||||
}
|
||||
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
||||
for _, p := range photoPaths {
|
||||
ph, err := qslcard.AnalyzePhoto(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze %s: %w", filepath.Base(p), err)
|
||||
}
|
||||
photos = append(photos, ph)
|
||||
}
|
||||
info, err := a.qslProfileInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var engine qslcard.LayoutEngine = qslcard.HeuristicEngine{}
|
||||
templates, err := engine.Propose(photos, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, len(templates))
|
||||
for i, t := range templates {
|
||||
b, err := qslcard.Encode(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = string(b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
|
||||
// shared/global ones), defaults first — so the default is per-profile, not a
|
||||
// single global list shared by every profile.
|
||||
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||||
if a.qslTemplates == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
var recs []qslcard.Record
|
||||
var err error
|
||||
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||
recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
|
||||
} else {
|
||||
recs, err = a.qslTemplates.List(a.ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]QSLTemplateInfo, 0, len(recs))
|
||||
for _, r := range recs {
|
||||
out = append(out, QSLTemplateInfo{
|
||||
ID: r.ID, Name: r.Name, ProfileID: r.ProfileID,
|
||||
IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QSLGetTemplate returns a stored template document (JSON).
|
||||
func (a *App) QSLGetTemplate(id int64) (string, error) {
|
||||
if a.qslTemplates == nil {
|
||||
return "", fmt.Errorf("db not initialized")
|
||||
}
|
||||
rec, err := a.qslTemplates.Get(a.ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rec.JSON, nil
|
||||
}
|
||||
|
||||
// QSLSaveTemplate validates and stores a template. id 0 creates; the photos
|
||||
// the document references at their original (absolute) paths are copied into
|
||||
// the template's asset folder so the design survives the user moving files.
|
||||
// Returns the template id.
|
||||
func (a *App) QSLSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) {
|
||||
if a.qslTemplates == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return 0, fmt.Errorf("template name required")
|
||||
}
|
||||
t, err := qslcard.Parse([]byte(doc))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
t.Name = name
|
||||
if err := qslcard.Validate(t, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rec := qslcard.Record{ID: id, Name: name}
|
||||
if forActiveProfile {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
rec.ProfileID = &p.ID
|
||||
}
|
||||
}
|
||||
// Two-phase save: the row first (a new template needs its id to own an
|
||||
// asset folder), then photo import + the final document.
|
||||
rec.JSON = doc
|
||||
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
dir := qslcard.TemplateDir(a.qslDir(), rec.ID)
|
||||
if err := qslcard.ImportPhotos(&t, dir); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
final, err := qslcard.Encode(t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rec.JSON = string(final)
|
||||
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applog.Printf("qsl: template %q saved (id %d)", name, rec.ID)
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
// QSLSetDefaultTemplate marks a template as default for its profile scope.
|
||||
func (a *App) QSLSetDefaultTemplate(id int64) error {
|
||||
if a.qslTemplates == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qslTemplates.SetDefault(a.ctx, id)
|
||||
}
|
||||
|
||||
// QSLDeleteTemplate removes a template and its asset folder.
|
||||
func (a *App) QSLDeleteTemplate(id int64) error {
|
||||
if a.qslTemplates == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.qslTemplates.Delete(a.ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return qslcard.RemoveTemplateDir(a.qslDir(), id)
|
||||
}
|
||||
|
||||
// QSLSavePreview stores the rasterized thumbnail (PNG, base64) the frontend
|
||||
// renders at save time, shown in the template list.
|
||||
func (a *App) QSLSavePreview(id int64, pngB64 string) error {
|
||||
data, err := base64.StdEncoding.DecodeString(pngB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode preview: %w", err)
|
||||
}
|
||||
dir := qslcard.TemplateDir(a.qslDir(), id)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create template dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "preview.png"), data, 0o644); err != nil {
|
||||
return fmt.Errorf("write preview: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QSLPreviewDataURL returns the stored thumbnail as a data URL ("" if the
|
||||
// template has none yet).
|
||||
func (a *App) QSLPreviewDataURL(id int64) (string, error) {
|
||||
data, err := os.ReadFile(filepath.Join(qslcard.TemplateDir(a.qslDir(), id), "preview.png"))
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read preview: %w", err)
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
// QSLPhotoDataURL returns a template photo as a data URL. templateID 0 reads
|
||||
// a draft photo at its original absolute path (pre-save editing); otherwise
|
||||
// ref is a name inside the template's asset folder.
|
||||
func (a *App) QSLPhotoDataURL(templateID int64, ref string) (string, error) {
|
||||
var path string
|
||||
switch {
|
||||
case templateID > 0 && !qslcard.IsDraftPhoto(ref):
|
||||
path = filepath.Join(qslcard.TemplateDir(a.qslDir(), templateID), filepath.Base(ref))
|
||||
case qslcard.IsDraftPhoto(ref):
|
||||
path = ref
|
||||
default:
|
||||
return "", fmt.Errorf("photo %q not available without a template id", ref)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read photo: %w", err)
|
||||
}
|
||||
mime := "image/jpeg"
|
||||
if strings.EqualFold(filepath.Ext(path), ".png") {
|
||||
mime = "image/png"
|
||||
}
|
||||
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
// QSLFlagDataURL returns an embedded flag SVG as a data URL ("" = no flag).
|
||||
func (a *App) QSLFlagDataURL(iso string) (string, error) {
|
||||
if iso == "" {
|
||||
return "", nil
|
||||
}
|
||||
svg, err := qslcard.FlagSVG(iso)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(svg), nil
|
||||
}
|
||||
|
||||
// QSLFonts returns the embedded designer fonts (plus Cooper Black when the
|
||||
// machine has it) for @font-face registration.
|
||||
func (a *App) QSLFonts() ([]QSLFontInfo, error) {
|
||||
fonts, err := qslcard.Fonts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]QSLFontInfo, 0, len(fonts))
|
||||
for _, f := range fonts {
|
||||
out = append(out, QSLFontInfo{
|
||||
Family: f.Family, Kind: f.Kind, Variable: f.Variable,
|
||||
DataB64: base64.StdEncoding.EncodeToString(f.Data),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QSLStylePresets lists the built-in style presets for the picker.
|
||||
func (a *App) QSLStylePresets() []QSLPresetInfo {
|
||||
out := make([]QSLPresetInfo, 0, len(qslcard.Presets))
|
||||
for _, p := range qslcard.Presets {
|
||||
params := make([]string, 0, len(p.AllowedParams))
|
||||
for k := range p.AllowedParams {
|
||||
params = append(params, k)
|
||||
}
|
||||
sort.Strings(params)
|
||||
out = append(out, QSLPresetInfo{Name: p.Name, Label: p.Label, Params: params, Defaults: p.Defaults})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// QSLResolvePreview fills a template's placeholders with the active profile
|
||||
// and a representative sample QSO, for the live editor preview. Returns the
|
||||
// RenderModel as JSON.
|
||||
func (a *App) QSLResolvePreview(doc string) (string, error) {
|
||||
t, err := qslcard.Parse([]byte(doc))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
vars, country, err := a.qslVars(sampleQSO())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encodeRenderModel(qslcard.Resolve(t, vars, country))
|
||||
}
|
||||
|
||||
// QSLDefaultTemplateID returns the template the eQSL send flow should use
|
||||
// for the active profile (0 when none exists yet).
|
||||
func (a *App) QSLDefaultTemplateID() (int64, error) {
|
||||
if a.qslTemplates == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("no active profile: %w", err)
|
||||
}
|
||||
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
|
||||
if err != nil {
|
||||
return 0, nil // no templates yet — the UI offers to open the designer
|
||||
}
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
// RenderEQSL loads a QSO and a template and returns the fully-resolved
|
||||
// render model (JSON) for the frontend to rasterize.
|
||||
func (a *App) RenderEQSL(qsoID int64, templateID int64) (string, error) {
|
||||
if a.qso == nil || a.qslTemplates == nil {
|
||||
return "", fmt.Errorf("db not initialized")
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, qsoID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec, err := a.qslTemplates.Get(a.ctx, templateID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("template %d: %w", templateID, err)
|
||||
}
|
||||
t, err := qslcard.Parse([]byte(rec.JSON))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := qslcard.TemplateDir(a.qslDir(), templateID)
|
||||
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
vars, country, err := a.qslVars(q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encodeRenderModel(qslcard.Resolve(t, vars, country))
|
||||
}
|
||||
|
||||
// SendEQSL e-mails the rasterized card (JPEG, base64) to the QSO's
|
||||
// correspondent, archives it in the outbox and stamps eqsl_sent on success.
|
||||
func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, qsoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to := strings.TrimSpace(q.Email)
|
||||
if to == "" {
|
||||
return fmt.Errorf("no e-mail address for %s — run a QRZ/HamQTH lookup first", q.Callsign)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(jpegB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode card image: %w", err)
|
||||
}
|
||||
outbox := filepath.Join(a.qslDir(), "outbox")
|
||||
if err := os.MkdirAll(outbox, 0o755); err != nil {
|
||||
return fmt.Errorf("create outbox: %w", err)
|
||||
}
|
||||
safeCall := strings.Map(func(r rune) rune {
|
||||
if r == '/' || r == '\\' || r == ':' {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, q.Callsign)
|
||||
path := filepath.Join(outbox, fmt.Sprintf("%s_%s.jpg", safeCall, q.QSODate.UTC().Format("20060102_1504")))
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write outbox file: %w", err)
|
||||
}
|
||||
|
||||
s, _ := a.GetEmailSettings()
|
||||
subject, _ := a.settings.Get(a.ctx, keyQSLEmailSubject)
|
||||
if subject == "" {
|
||||
subject = defaultQSLEmailSubject
|
||||
}
|
||||
body, _ := a.settings.Get(a.ctx, keyQSLEmailBody)
|
||||
if body == "" {
|
||||
body = defaultQSLEmailBody
|
||||
}
|
||||
if err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), path); err != nil {
|
||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||
return err
|
||||
}
|
||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
||||
// row unchanged apart from this field.
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
||||
}
|
||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
|
||||
type QSLEmailTemplates struct {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AutoSend bool `json:"auto_send"`
|
||||
}
|
||||
|
||||
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
||||
func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||
out := QSLEmailTemplates{Subject: defaultQSLEmailSubject, Body: defaultQSLEmailBody}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if s := m[keyQSLEmailSubject]; s != "" {
|
||||
out.Subject = s
|
||||
}
|
||||
if b := m[keyQSLEmailBody]; b != "" {
|
||||
out.Body = b
|
||||
}
|
||||
out.AutoSend = m[keyQSLAutoSend] == "1"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag.
|
||||
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
v := "0"
|
||||
if t.AutoSend {
|
||||
v = "1"
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
|
||||
}
|
||||
|
||||
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
|
||||
// user enabled auto-send and the prerequisites hold: a recipient e-mail, a
|
||||
// default template for the active profile, and the QSO not already confirmed.
|
||||
// Rasterization happens in the webview, so this only emits an event ("qsl:autosend")
|
||||
// the frontend acts on — never sends from here. Silent when any condition fails.
|
||||
func (a *App) maybeAutoSendEQSL(q qso.QSO) {
|
||||
if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil {
|
||||
return
|
||||
}
|
||||
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
|
||||
return
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
|
||||
if err != nil {
|
||||
applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err)
|
||||
return
|
||||
}
|
||||
wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{
|
||||
"qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign,
|
||||
})
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
func encodeRenderModel(m qslcard.RenderModel) (string, error) {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode render model: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// qslProfileInfo extracts the placement-engine inputs from the active
|
||||
// profile, filling missing zones from cty.dat.
|
||||
func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
|
||||
if a.profiles == nil {
|
||||
return qslcard.ProfileInfo{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return qslcard.ProfileInfo{}, fmt.Errorf("no active profile: %w", err)
|
||||
}
|
||||
info := qslcard.ProfileInfo{
|
||||
Callsign: p.Callsign,
|
||||
Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
|
||||
Grid: p.MyGrid,
|
||||
Rig: p.MyRig,
|
||||
Antenna: p.MyAntenna,
|
||||
}
|
||||
if p.MyCQZone != nil {
|
||||
info.CQZone = *p.MyCQZone
|
||||
}
|
||||
if p.MyITUZone != nil {
|
||||
info.ITUZone = *p.MyITUZone
|
||||
}
|
||||
if (info.CQZone == 0 || info.ITUZone == 0) && a.dxcc != nil {
|
||||
if m, ok := a.dxcc.Lookup(p.Callsign); ok {
|
||||
if info.CQZone == 0 {
|
||||
info.CQZone = m.CQZone
|
||||
}
|
||||
if info.ITUZone == 0 {
|
||||
info.ITUZone = m.ITUZone
|
||||
}
|
||||
}
|
||||
}
|
||||
// Most users define rig/antenna in the operating manager (band defaults),
|
||||
// not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band
|
||||
// default so the designer knows the station line has data to show.
|
||||
if (info.Rig == "" || info.Antenna == "") && a.operating != nil {
|
||||
for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} {
|
||||
d, _, e := a.operating.BandDefault(a.ctx, p.ID, band)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
if info.Rig == "" {
|
||||
info.Rig = d.StationName
|
||||
}
|
||||
if info.Antenna == "" {
|
||||
info.Antenna = d.AntennaName
|
||||
}
|
||||
if info.Rig != "" && info.Antenna != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// qslRigAntenna resolves the rig and antenna names for a QSO: the values
|
||||
// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's
|
||||
// default for the QSO's band (so the preview and back-entered QSOs aren't
|
||||
// blank).
|
||||
func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) {
|
||||
rig, ant = q.MyRig, q.MyAntenna
|
||||
if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil {
|
||||
return rig, ant
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return rig, ant
|
||||
}
|
||||
band := q.Band
|
||||
if band == "" {
|
||||
band = "20m"
|
||||
}
|
||||
if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil {
|
||||
if rig == "" {
|
||||
rig = d.StationName
|
||||
}
|
||||
if ant == "" {
|
||||
ant = d.AntennaName
|
||||
}
|
||||
}
|
||||
return rig, ant
|
||||
}
|
||||
|
||||
// qslVars builds the full placeholder map (profile + QSO) and the country
|
||||
// block resolution for one render.
|
||||
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
|
||||
info, err := a.qslProfileInfo()
|
||||
if err != nil {
|
||||
return nil, qslcard.CountryInfo{}, err
|
||||
}
|
||||
zone := func(z int) string { // unknown zone reads better blank than "0"
|
||||
if z == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(z)
|
||||
}
|
||||
vars := map[string]string{
|
||||
"profile.callsign": info.Callsign,
|
||||
"profile.operator_name": info.Operator,
|
||||
"profile.grid": info.Grid,
|
||||
"profile.cq_zone": zone(info.CQZone),
|
||||
"profile.itu_zone": zone(info.ITUZone),
|
||||
"profile.rig": info.Rig,
|
||||
"profile.antenna": info.Antenna,
|
||||
|
||||
"qso.callsign": q.Callsign,
|
||||
"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
|
||||
"qso.time_on": q.QSODate.UTC().Format("15:04"),
|
||||
"qso.band": q.Band,
|
||||
"qso.mode": q.Mode,
|
||||
"qso.submode": q.Submode,
|
||||
"qso.rst_sent": q.RSTSent,
|
||||
"qso.qsl_msg": q.QSLMsg,
|
||||
"qso.name": q.Name,
|
||||
}
|
||||
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||
if q.FreqHz != nil {
|
||||
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
|
||||
}
|
||||
return vars, a.qslCountryInfo(), nil
|
||||
}
|
||||
|
||||
// qslCountryInfo resolves the operator's own country (label + flag) from the
|
||||
// active profile: explicit MY_DXCC wins, then a cty.dat lookup of the
|
||||
// callsign. No match → label only or nothing (graceful).
|
||||
func (a *App) qslCountryInfo() qslcard.CountryInfo {
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return qslcard.CountryInfo{}
|
||||
}
|
||||
out := qslcard.CountryInfo{Label: p.MyCountry}
|
||||
entityNum := 0
|
||||
if p.MyDXCC != nil {
|
||||
entityNum = *p.MyDXCC
|
||||
if out.Label == "" {
|
||||
out.Label = dxcc.NameForDXCC(entityNum)
|
||||
}
|
||||
} else if a.dxcc != nil {
|
||||
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
|
||||
if out.Label == "" {
|
||||
out.Label = m.Entity.Name
|
||||
}
|
||||
entityNum = dxcc.EntityDXCC(m.Entity.Name)
|
||||
}
|
||||
}
|
||||
out.FlagISO = qslcard.FlagISO(entityNum)
|
||||
return out
|
||||
}
|
||||
|
||||
// sampleQSO is the canned contact used by the editor's live preview.
|
||||
func sampleQSO() qso.QSO {
|
||||
return qso.QSO{
|
||||
Callsign: "DL1ABC",
|
||||
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
|
||||
Band: "20m",
|
||||
Mode: "SSB",
|
||||
RSTSent: "59",
|
||||
QSLMsg: "TNX FB QSO — 73!",
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/secret"
|
||||
)
|
||||
|
||||
// Secret-vault settings keys (NOT themselves sensitive — they hold the salt and
|
||||
// an encrypted verifier token, never a password).
|
||||
const (
|
||||
keySecretSalt = "secret.salt" // base64 PBKDF2 salt
|
||||
keySecretVerifier = "secret.verifier" // enc:v1: token, validates the passphrase
|
||||
)
|
||||
|
||||
// sensitiveSettingKeys are the password fields encrypted at rest when the user
|
||||
// sets a passphrase. Everything else stays plaintext.
|
||||
var sensitiveSettingKeys = map[string]bool{
|
||||
keyQRZPassword: true,
|
||||
keyHQPassword: true,
|
||||
keyEmailPassword: true,
|
||||
keyExtClublogPassword: true,
|
||||
keyExtLoTWKeyPassword: true,
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
}
|
||||
|
||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||
|
||||
// SecretStatus tells the UI whether a passphrase is configured and unlocked.
|
||||
type SecretStatus struct {
|
||||
HasPassphrase bool `json:"has_passphrase"`
|
||||
Unlocked bool `json:"unlocked"`
|
||||
}
|
||||
|
||||
// GetSecretStatus is polled at launch: if HasPassphrase && !Unlocked the UI
|
||||
// shows the unlock prompt.
|
||||
func (a *App) GetSecretStatus() SecretStatus {
|
||||
if a.settings == nil {
|
||||
return SecretStatus{}
|
||||
}
|
||||
salt, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
|
||||
return SecretStatus{HasPassphrase: salt != "", Unlocked: a.settings.Unlocked()}
|
||||
}
|
||||
|
||||
// reloadAfterSecretChange rebuilds anything that read passwords at startup while
|
||||
// they were still locked (lookup providers + external-service config).
|
||||
func (a *App) reloadAfterSecretChange() {
|
||||
a.reloadLookupProviders()
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockSecrets validates the passphrase against the stored verifier and, on
|
||||
// success, installs the cipher so passwords decrypt for the rest of the session.
|
||||
func (a *App) UnlockSecrets(passphrase string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("not initialized")
|
||||
}
|
||||
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
|
||||
verifier, _ := a.settings.GetRaw(a.ctx, keySecretVerifier)
|
||||
if saltB64 == "" || verifier == "" {
|
||||
return fmt.Errorf("no passphrase configured")
|
||||
}
|
||||
salt, err := base64.StdEncoding.DecodeString(saltB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("corrupt salt")
|
||||
}
|
||||
key, err := secret.DeriveKey(passphrase, salt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive key: %w", err)
|
||||
}
|
||||
c, err := secret.New(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cipher: %w", err)
|
||||
}
|
||||
if !c.CheckVerifier(verifier) {
|
||||
return fmt.Errorf("wrong passphrase")
|
||||
}
|
||||
a.settings.Unlock(c)
|
||||
a.reloadAfterSecretChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPassphrase sets a new passphrase or changes an existing one, then
|
||||
// re-encrypts every stored secret under the new key. Changing an existing
|
||||
// passphrase requires the vault to be unlocked first (so current secrets are
|
||||
// readable as plaintext).
|
||||
func (a *App) SetPassphrase(passphrase string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("not initialized")
|
||||
}
|
||||
if len(passphrase) < 4 {
|
||||
return fmt.Errorf("passphrase too short (min 4 characters)")
|
||||
}
|
||||
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
|
||||
if saltB64 != "" && !a.settings.Unlocked() {
|
||||
return fmt.Errorf("unlock with the current passphrase first")
|
||||
}
|
||||
// Snapshot current plaintext (Get decrypts when unlocked, passes through
|
||||
// when no passphrase yet).
|
||||
current := map[string]string{}
|
||||
for k := range sensitiveSettingKeys {
|
||||
if v, _ := a.settings.Get(a.ctx, k); v != "" {
|
||||
current[k] = v
|
||||
}
|
||||
}
|
||||
salt, err := secret.NewSalt()
|
||||
if err != nil {
|
||||
return fmt.Errorf("salt: %w", err)
|
||||
}
|
||||
key, err := secret.DeriveKey(passphrase, salt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive key: %w", err)
|
||||
}
|
||||
c, err := secret.New(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cipher: %w", err)
|
||||
}
|
||||
// Persist salt + verifier first so an interrupted migration is still
|
||||
// recoverable (the prompt appears next launch; plaintext secrets read
|
||||
// through transparently until re-encrypted).
|
||||
if err := a.settings.SetRaw(a.ctx, keySecretSalt, base64.StdEncoding.EncodeToString(salt)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.SetRaw(a.ctx, keySecretVerifier, c.MakeVerifier()); err != nil {
|
||||
return err
|
||||
}
|
||||
a.settings.Unlock(c)
|
||||
for k, v := range current {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil { // now encrypts
|
||||
applog.Printf("secret: re-encrypt %q failed: %v", k, err)
|
||||
}
|
||||
}
|
||||
a.reloadAfterSecretChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePassphrase decrypts all secrets back to plaintext and clears the vault.
|
||||
// Requires the current passphrase (validated) so it can't be cleared blindly.
|
||||
func (a *App) RemovePassphrase(passphrase string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("not initialized")
|
||||
}
|
||||
if !a.settings.Unlocked() {
|
||||
if err := a.UnlockSecrets(passphrase); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Read decrypted, then store back as plaintext and drop salt/verifier.
|
||||
plain := map[string]string{}
|
||||
for k := range sensitiveSettingKeys {
|
||||
if v, _ := a.settings.Get(a.ctx, k); v != "" {
|
||||
plain[k] = v
|
||||
}
|
||||
}
|
||||
a.settings.Lock()
|
||||
for k, v := range plain {
|
||||
if err := a.settings.SetRaw(a.ctx, k, v); err != nil {
|
||||
applog.Printf("secret: decrypt-on-remove %q failed: %v", k, err)
|
||||
}
|
||||
}
|
||||
_ = a.settings.SetRaw(a.ctx, keySecretSalt, "")
|
||||
_ = a.settings.SetRaw(a.ctx, keySecretVerifier, "")
|
||||
a.reloadAfterSecretChange()
|
||||
return nil
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
|
||||
// launches on startup (JSON array of AutostartProgram).
|
||||
const keyAutostartPrograms = "autostart.programs"
|
||||
|
||||
// AutostartProgram is one external application OpsLog can launch when it starts
|
||||
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
|
||||
type AutostartProgram struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Args string `json:"args"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// AutostartLaunchResult reports what happened for one program when launching.
|
||||
type AutostartLaunchResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // launched | already_running | missing | disabled | error
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// GetAutostartPrograms returns the active profile's autostart list.
|
||||
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
|
||||
out := []AutostartProgram{}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return []AutostartProgram{}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveAutostartPrograms persists the autostart list for the active profile.
|
||||
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
b, err := json.Marshal(progs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
|
||||
}
|
||||
|
||||
// BrowseExecutable opens a native file picker for choosing a program to launch.
|
||||
func (a *App) BrowseExecutable() (string, error) {
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose a program to launch on startup",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// LaunchAutostartPrograms starts every enabled program that isn't already
|
||||
// running, returning a per-program result. Used at startup (best effort) and by
|
||||
// the "Launch now" button in settings.
|
||||
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
|
||||
progs, _ := a.GetAutostartPrograms()
|
||||
running := runningProcessNames()
|
||||
out := make([]AutostartLaunchResult, 0, len(progs))
|
||||
for _, p := range progs {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, launchProgram(p, running))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LaunchAutostartProgram launches a single program by id on demand (the per-row
|
||||
// "launch now" action), regardless of its enabled flag.
|
||||
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
|
||||
progs, err := a.GetAutostartPrograms()
|
||||
if err != nil {
|
||||
return AutostartLaunchResult{}, err
|
||||
}
|
||||
for _, p := range progs {
|
||||
if p.ID == id {
|
||||
return launchProgram(p, runningProcessNames()), nil
|
||||
}
|
||||
}
|
||||
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
||||
}
|
||||
|
||||
// launchProgram starts one program unless its executable is already running.
|
||||
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
||||
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
||||
path := strings.TrimSpace(p.Path)
|
||||
if path == "" {
|
||||
res.Status, res.Message = "error", "no path configured"
|
||||
return res
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
res.Status, res.Message = "missing", "executable not found: "+path
|
||||
return res
|
||||
}
|
||||
// Skip if a process with the same executable name is already running, so we
|
||||
// never spawn a second copy of WSJT-X / JTAlert / etc.
|
||||
base := strings.ToLower(filepath.Base(path))
|
||||
if running[base] {
|
||||
res.Status, res.Message = "already_running", base+" already running"
|
||||
return res
|
||||
}
|
||||
cmd := exec.Command(path, splitArgs(p.Args)...)
|
||||
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
|
||||
if err := cmd.Start(); err != nil {
|
||||
res.Status, res.Message = "error", err.Error()
|
||||
return res
|
||||
}
|
||||
// Don't wait on the child — it runs independently of OpsLog. Release the
|
||||
// handle so we don't accumulate zombies.
|
||||
go func() { _ = cmd.Wait() }()
|
||||
res.Status, res.Message = "launched", "started "+base
|
||||
return res
|
||||
}
|
||||
|
||||
// splitArgs does a minimal shell-like split of an argument string, honouring
|
||||
// double quotes so a path with spaces stays one argument.
|
||||
func splitArgs(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var args []string
|
||||
var cur strings.Builder
|
||||
inQuote := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '"':
|
||||
inQuote = !inQuote
|
||||
case r == ' ' && !inQuote:
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// runningProcessNames returns the set of lowercase executable names currently
|
||||
// running, via the Windows `tasklist`. Best effort — on failure the set is
|
||||
// empty (we then just attempt to launch, which is acceptable).
|
||||
func runningProcessNames() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
applog.Printf("autostart: tasklist failed: %v", err)
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// CSV row: "image.exe","PID",... — take the first quoted field.
|
||||
field := line
|
||||
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
|
||||
field = line[1 : i+1]
|
||||
}
|
||||
field = strings.Trim(field, "\"")
|
||||
if field != "" {
|
||||
out[strings.ToLower(field)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/award"
|
||||
)
|
||||
|
||||
// The catalog is the channel through which a shipped award is both DELIVERED and
|
||||
// CORRECTED. These tests pin the three rules that make that safe:
|
||||
// - a new catalog award reaches an operator who already has awards stored;
|
||||
// - a fixed one (higher Version) replaces the stored copy;
|
||||
// - unless the operator has edited it, in which case their work wins.
|
||||
|
||||
func catalogDef(t *testing.T, code string) award.Def {
|
||||
t.Helper()
|
||||
for _, d := range award.Defaults() {
|
||||
if d.Code == code {
|
||||
return d
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is not in the catalog", code)
|
||||
return award.Def{}
|
||||
}
|
||||
|
||||
func findDef(defs []award.Def, code string) (award.Def, bool) {
|
||||
for _, d := range defs {
|
||||
if d.Code == code {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return award.Def{}, false
|
||||
}
|
||||
|
||||
func TestMergeCatalogAddsMissingAward(t *testing.T) {
|
||||
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
|
||||
got, updated, changed := mergeCatalog(stored)
|
||||
if !changed {
|
||||
t.Fatal("merge reported no change, but every catalog award was missing")
|
||||
}
|
||||
if len(updated) != 0 {
|
||||
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
|
||||
}
|
||||
if _, ok := findDef(got, "FFMA"); !ok {
|
||||
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
|
||||
}
|
||||
if _, ok := findDef(got, "MYOWN"); !ok {
|
||||
t.Error("the operator's own award was dropped by the merge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
|
||||
// The stored copy is an older revision of a shipped award, with a broken rule.
|
||||
old := catalogDef(t, "FFMA")
|
||||
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||
old.Field = "wrong"
|
||||
old.Valid = false // the operator disabled it — a preference, not a definition
|
||||
|
||||
got, updated, changed := mergeCatalog([]award.Def{old})
|
||||
if !changed || len(updated) == 0 {
|
||||
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
|
||||
}
|
||||
d, _ := findDef(got, "FFMA")
|
||||
if d.Field != "grid4" {
|
||||
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
|
||||
}
|
||||
if d.Valid {
|
||||
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
|
||||
old := catalogDef(t, "FFMA")
|
||||
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||
old.Field = "mine"
|
||||
old.UserEdited = true
|
||||
|
||||
got, updated, _ := mergeCatalog([]award.Def{old})
|
||||
if len(updated) != 0 {
|
||||
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
|
||||
}
|
||||
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
|
||||
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
|
||||
prev := []award.Def{
|
||||
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
|
||||
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
|
||||
}
|
||||
next := []award.Def{
|
||||
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
|
||||
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
|
||||
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
|
||||
}
|
||||
markUserEdited(next, prev)
|
||||
|
||||
if next[0].UserEdited {
|
||||
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
|
||||
}
|
||||
if !next[1].UserEdited {
|
||||
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
|
||||
}
|
||||
if next[2].UserEdited {
|
||||
t.Error("C is a brand-new award; there is no shipped version to protect it from")
|
||||
}
|
||||
// A save must not pretend to be a new shipped revision.
|
||||
if next[0].Version != 2 || next[1].Version != 2 {
|
||||
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// Multi-operator chat over the SHARED MySQL logbook. The database is the message
|
||||
// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so
|
||||
// operators on the same shared log (e.g. a special-event call) can talk. No
|
||||
// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is
|
||||
// only available on a MySQL logbook (SQLite/solo has no one else to talk to).
|
||||
|
||||
const (
|
||||
chatPollInterval = 3 * time.Second
|
||||
chatPresenceEvery = 20 * time.Second
|
||||
chatRetentionDays = 7
|
||||
chatHistoryDefault = 80
|
||||
chatPresenceStaleSecs = 120 // a presence row older than this = offline
|
||||
)
|
||||
|
||||
// ChatMessage is one chat line.
|
||||
type ChatMessage struct {
|
||||
ID int64 `json:"id"`
|
||||
Operator string `json:"operator"`
|
||||
Station string `json:"station"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"` // ISO UTC
|
||||
}
|
||||
|
||||
// ChatPresence is one operator currently online (recent heartbeat).
|
||||
type ChatPresence struct {
|
||||
Operator string `json:"operator"`
|
||||
Station string `json:"station"`
|
||||
AgoSecs int `json:"ago_secs"`
|
||||
}
|
||||
|
||||
// chatActive reports whether chat can run (shared MySQL logbook).
|
||||
func (a *App) chatActive() bool {
|
||||
return a.logDb != nil && a.dbBackend == "mysql"
|
||||
}
|
||||
|
||||
// ChatAvailable lets the UI show/hide the chat icon (only on a shared log).
|
||||
func (a *App) ChatAvailable() bool { return a.chatActive() }
|
||||
|
||||
func (a *App) ensureChatTables() error {
|
||||
if _, err := a.logDb.ExecContext(a.ctx,
|
||||
"CREATE TABLE IF NOT EXISTS chat_messages ("+
|
||||
"id BIGINT AUTO_INCREMENT PRIMARY KEY, "+
|
||||
"operator VARCHAR(32), station VARCHAR(32), "+
|
||||
"message TEXT, created_at DATETIME)"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
"CREATE TABLE IF NOT EXISTS chat_presence ("+
|
||||
"operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)")
|
||||
return err
|
||||
}
|
||||
|
||||
// SendChatMessage posts a message to the shared chat and returns the stored row
|
||||
// (with its id) so the UI can show it immediately; the poll loop dedupes by id.
|
||||
func (a *App) SendChatMessage(text string) (ChatMessage, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ChatMessage{}, nil
|
||||
}
|
||||
if len(text) > 1000 {
|
||||
text = text[:1000]
|
||||
}
|
||||
if !a.chatActive() {
|
||||
return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook")
|
||||
}
|
||||
op, station := a.liveStatusOperator()
|
||||
if op == "" {
|
||||
return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first")
|
||||
}
|
||||
if err := a.ensureChatTables(); err != nil {
|
||||
return ChatMessage{}, err
|
||||
}
|
||||
res, err := a.logDb.ExecContext(a.ctx,
|
||||
"INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())",
|
||||
op, station, text)
|
||||
if err != nil {
|
||||
return ChatMessage{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return ChatMessage{ID: id, Operator: op, Station: station, Message: text,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
// GetChatHistory returns the most recent messages (oldest first) for the panel.
|
||||
func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) {
|
||||
if !a.chatActive() {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = chatHistoryDefault
|
||||
}
|
||||
if err := a.ensureChatTables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := a.logDb.QueryContext(a.ctx,
|
||||
"SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ChatMessage
|
||||
for rows.Next() {
|
||||
var m ChatMessage
|
||||
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
// Reverse to chronological order (we queried newest-first to honour LIMIT).
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetOnlineOperators lists operators with a recent presence heartbeat.
|
||||
func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
|
||||
if !a.chatActive() {
|
||||
return nil, nil
|
||||
}
|
||||
if err := a.ensureChatTables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := a.logDb.QueryContext(a.ctx,
|
||||
"SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+
|
||||
"FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator",
|
||||
chatPresenceStaleSecs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ChatPresence
|
||||
for rows.Next() {
|
||||
var p ChatPresence
|
||||
if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// chatLoop polls for new messages and heartbeats presence while on a shared
|
||||
// MySQL logbook. Started once at startup; a cheap no-op otherwise.
|
||||
func (a *App) chatLoop() {
|
||||
defer func() { _ = recover() }()
|
||||
var lastID int64 = -1 // -1 = not yet baselined
|
||||
var lastDB *sql.DB // logbook the baseline belongs to
|
||||
lastPresence := time.Time{}
|
||||
lastPurge := time.Time{}
|
||||
t := time.NewTicker(chatPollInterval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
if !a.chatActive() {
|
||||
lastID = -1 // re-baseline if the backend changes
|
||||
lastDB = nil
|
||||
continue
|
||||
}
|
||||
// Profile switch swaps the logbook under us: re-baseline against the new
|
||||
// DB so we don't query it with the previous log's id cursor.
|
||||
if a.logDb != lastDB {
|
||||
lastID = -1
|
||||
lastDB = a.logDb
|
||||
}
|
||||
if err := a.ensureChatTables(); err != nil {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
// Presence heartbeat.
|
||||
if now.Sub(lastPresence) >= chatPresenceEvery {
|
||||
if op, station := a.liveStatusOperator(); op != "" {
|
||||
_, _ = a.logDb.ExecContext(a.ctx,
|
||||
"INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+
|
||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()",
|
||||
op, station)
|
||||
}
|
||||
lastPresence = now
|
||||
}
|
||||
// Baseline on first run so existing history isn't replayed as "new"
|
||||
// (the panel loads it via GetChatHistory).
|
||||
if lastID < 0 {
|
||||
row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages")
|
||||
_ = row.Scan(&lastID)
|
||||
continue
|
||||
}
|
||||
// Emit new messages.
|
||||
rows, err := a.logDb.QueryContext(a.ctx,
|
||||
"SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var m ChatMessage
|
||||
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if m.ID > lastID {
|
||||
lastID = m.ID
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "chat:message", m)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
// Purge old messages occasionally (hourly).
|
||||
if now.Sub(lastPurge) >= time.Hour {
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
"DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays)
|
||||
if err != nil {
|
||||
applog.Printf("chat: purge failed: %v", err)
|
||||
}
|
||||
lastPurge = now
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// Command icomnettest is an iteration probe for the Icom IP remote protocol —
|
||||
// the LAN server built into the IC-7610 that the Icom "Remote Utility" (and
|
||||
// wfview) talk to. OpsLog reimplements this directly so it can BE both the
|
||||
// Remote Utility (Ethernet ↔ radio) and the logger/CAT client, dropping the
|
||||
// virtual-COM + RS-BA1 chain entirely.
|
||||
//
|
||||
// This probe drives TWO streams and hex-dumps everything:
|
||||
// Control (UDP 50001): handshake → login → token [VERIFIED on the real rig]
|
||||
// CI-V (UDP 50002): handshake → openClose(open) → send CI-V read-freq
|
||||
// (FE FE 98 E0 03 FD) → print the rig's reply.
|
||||
// Framing (passcode table, packet offsets, CI-V data_packet, openclose) is
|
||||
// reimplemented from the public wfview protocol and verified byte-for-byte
|
||||
// against real Remote-Utility captures (build/bin/civ*.pcapng). No GPLv3 code.
|
||||
//
|
||||
// Usage:
|
||||
// go run ./cmd/icomnettest <rig-ip> <user> <pass> [compname]
|
||||
//
|
||||
// SAFE: read-only CI-V (operating frequency). No TX, no writes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var le = binary.LittleEndian
|
||||
var be = binary.BigEndian
|
||||
|
||||
// passcodeSeq — Icom's obfuscation table (values live at index 0x20..0x7e).
|
||||
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
||||
var passcodeSeq = [256]byte{
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
|
||||
0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
|
||||
0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
|
||||
0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
|
||||
0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
|
||||
}
|
||||
|
||||
func passcode(s string) []byte {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s) && i < 16; i++ {
|
||||
p := int(s[i]) + i
|
||||
if p > 126 {
|
||||
p = 32 + p%127
|
||||
}
|
||||
out = append(out, passcodeSeq[p])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- packet builders (offsets verified vs wfview structs + real captures) ---
|
||||
|
||||
func ctrlPacket(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
||||
b := make([]byte, 16)
|
||||
le.PutUint32(b[0:], 0x10)
|
||||
le.PutUint16(b[4:], typ)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
return b
|
||||
}
|
||||
|
||||
func buildLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
|
||||
b := make([]byte, 0x80)
|
||||
le.PutUint32(b[0:], 0x80)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
be.PutUint32(b[0x10:], 0x80-0x10)
|
||||
b[0x14] = 0x01 // requestreply
|
||||
b[0x15] = 0x00 // requesttype = login
|
||||
be.PutUint16(b[0x16:], innerSeq)
|
||||
le.PutUint16(b[0x1a:], tokReq)
|
||||
le.PutUint32(b[0x1c:], token)
|
||||
copy(b[0x40:0x50], passcode(user))
|
||||
copy(b[0x50:0x60], passcode(pass))
|
||||
nm := name
|
||||
if len(nm) > 16 {
|
||||
nm = nm[:16]
|
||||
}
|
||||
copy(b[0x60:0x70], []byte(nm))
|
||||
return b
|
||||
}
|
||||
|
||||
func buildToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||
b := make([]byte, 0x40)
|
||||
le.PutUint32(b[0:], 0x40)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
be.PutUint32(b[0x10:], 0x40-0x10)
|
||||
b[0x14] = 0x01 // requestreply
|
||||
b[0x15] = 0x02 // requesttype = token
|
||||
be.PutUint16(b[0x16:], innerSeq)
|
||||
le.PutUint16(b[0x1a:], tokReq)
|
||||
le.PutUint32(b[0x1c:], token)
|
||||
return b
|
||||
}
|
||||
|
||||
// buildConnInfo — 144-byte sendRequestStream on the CONTROL stream. Tells the
|
||||
// rig to route the CI-V/audio streams to the authenticated session and which
|
||||
// local ports we use. Values verified byte-for-byte vs a real Remote-Utility
|
||||
// capture (civ4): requesttype=0x03, commoncap=0x8010, the rig's MAC echoed,
|
||||
// name "IC-7610", scrambled username, rxenable=0 (audio off — CI-V only),
|
||||
// rxcodec 0x10 / txcodec 0x04, rxsample 16000 / txsample 8000 (BE), civport /
|
||||
// audioport (BE), txbuffer 100.
|
||||
func buildConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
||||
b := make([]byte, 0x90)
|
||||
le.PutUint32(b[0:], 0x90)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
be.PutUint32(b[0x10:], 0x90-0x10)
|
||||
b[0x14] = 0x01 // requestreply
|
||||
b[0x15] = 0x03 // requesttype = conninfo / open streams
|
||||
be.PutUint16(b[0x16:], innerSeq)
|
||||
le.PutUint16(b[0x1a:], tokReq)
|
||||
le.PutUint32(b[0x1c:], token)
|
||||
le.PutUint16(b[0x27:], 0x8010) // commoncap
|
||||
copy(b[0x2a:0x30], rigMAC) // macaddress (the rig's, echoed back)
|
||||
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||
copy(b[0x60:0x70], passcode(user))
|
||||
b[0x70] = 0x00 // rxenable (0 = audio off)
|
||||
b[0x71] = 0x00 // txenable
|
||||
b[0x72] = 0x10 // rxcodec
|
||||
b[0x73] = 0x04 // txcodec
|
||||
be.PutUint32(b[0x74:], 16000) // rxsample
|
||||
be.PutUint32(b[0x78:], 8000) // txsample
|
||||
be.PutUint32(b[0x7c:], uint32(civPort))
|
||||
be.PutUint32(b[0x80:], uint32(audioPort))
|
||||
be.PutUint32(b[0x84:], 100) // txbuffer
|
||||
b[0x88] = 0x00 // convert
|
||||
return b
|
||||
}
|
||||
|
||||
// buildOpenClose — 22-byte start/stop for the CI-V stream. magic 0x04=open,
|
||||
// 0x00=close. data=0x01c0 (@0x10), civSeq (BE @0x13), magic (@0x15).
|
||||
func buildOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
|
||||
b := make([]byte, 0x16)
|
||||
le.PutUint32(b[0:], 0x16)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
le.PutUint16(b[0x10:], 0x01c0)
|
||||
be.PutUint16(b[0x13:], civSeq)
|
||||
b[0x15] = magic
|
||||
return b
|
||||
}
|
||||
|
||||
// buildCivData — wraps raw CI-V bytes: 21-byte header (reply 0xc1 @0x10,
|
||||
// datalen LE @0x11, civSeq BE @0x13) + CI-V frame @0x15.
|
||||
func buildCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
|
||||
n := 0x15 + len(civ)
|
||||
b := make([]byte, n)
|
||||
le.PutUint32(b[0:], uint32(n))
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
b[0x10] = 0xc1
|
||||
le.PutUint16(b[0x11:], uint16(len(civ)))
|
||||
be.PutUint16(b[0x13:], civSeq)
|
||||
copy(b[0x15:], civ)
|
||||
return b
|
||||
}
|
||||
|
||||
func header(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) {
|
||||
if len(b) < 16 {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
return le.Uint32(b[0:]), le.Uint16(b[4:]), le.Uint16(b[6:]), le.Uint32(b[8:]), le.Uint32(b[12:]), true
|
||||
}
|
||||
|
||||
func localID(conn net.Conn) uint32 {
|
||||
a := conn.LocalAddr().(*net.UDPAddr)
|
||||
return uint32(a.IP.To4()[0])<<24 | uint32(a.IP.To4()[1])<<16 | uint32(uint16(a.Port))
|
||||
}
|
||||
|
||||
func recv(conn net.Conn, ms int, buf []byte) ([]byte, bool) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return append([]byte(nil), buf[:n]...), true
|
||||
}
|
||||
|
||||
func dump(tag string, p []byte) { fmt.Printf("%s (%d)\n%s\n", tag, len(p), hex.Dump(p)) }
|
||||
|
||||
// pingReply mirrors a ping, swaps ids, sets the reply flag at offset 16.
|
||||
func pingReply(pkt []byte, myID, remoteID uint32) []byte {
|
||||
r := append([]byte(nil), pkt...)
|
||||
if len(r) >= 17 {
|
||||
le.PutUint32(r[8:], myID)
|
||||
le.PutUint32(r[12:], remoteID)
|
||||
r[16] = 0x01
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// handshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
|
||||
// Returns the rig's remote id. Replies to any pings meanwhile.
|
||||
func handshake(conn net.Conn, myID uint32, label string) (uint32, bool) {
|
||||
buf := make([]byte, 2048)
|
||||
conn.Write(ctrlPacket(0x03, 0, myID, 0)) // areYouThere
|
||||
fmt.Printf("[%s] TX areYouThere\n", label)
|
||||
var remoteID uint32
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
lastTry := time.Now()
|
||||
for time.Now().Before(deadline) {
|
||||
p, ok := recv(conn, 200, buf)
|
||||
if !ok {
|
||||
if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
|
||||
conn.Write(ctrlPacket(0x03, 0, myID, 0))
|
||||
lastTry = time.Now()
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, typ, _, sentid, _, ok := header(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typ {
|
||||
case 0x04: // iAmHere
|
||||
remoteID = sentid
|
||||
fmt.Printf("[%s] iAmHere remoteID=0x%08X → TX areYouReady\n", label, remoteID)
|
||||
conn.Write(ctrlPacket(0x06, 1, myID, remoteID))
|
||||
case 0x06: // iAmReady
|
||||
if remoteID != 0 {
|
||||
fmt.Printf("[%s] iAmReady — link up ✓\n", label)
|
||||
return remoteID, true
|
||||
}
|
||||
case 0x07: // ping
|
||||
conn.Write(pingReply(p, myID, remoteID))
|
||||
}
|
||||
}
|
||||
return remoteID, false
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("usage: icomnettest <rig-ip> <user> <pass> [compname]")
|
||||
os.Exit(2)
|
||||
}
|
||||
ip, user, pass := os.Args[1], os.Args[2], os.Args[3]
|
||||
compName := "OpsLog"
|
||||
if len(os.Args) >= 5 {
|
||||
compName = os.Args[4]
|
||||
}
|
||||
|
||||
// ===================== CONTROL STREAM (50001) =====================
|
||||
ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001"))
|
||||
if err != nil {
|
||||
fmt.Printf("dial control: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
cID := localID(ctrl)
|
||||
fmt.Printf("=== CONTROL 50001 (myID=0x%08X) ===\n", cID)
|
||||
fmt.Printf("scrambled user=% X pass=% X\n\n", passcode(user), passcode(pass))
|
||||
|
||||
cRemote, ok := handshake(ctrl, cID, "ctrl")
|
||||
if !ok {
|
||||
fmt.Println("control handshake failed")
|
||||
return
|
||||
}
|
||||
|
||||
// login → token
|
||||
var cTracked uint16 = 1
|
||||
var cInner uint16 = 1
|
||||
tokReq := uint16(0x0c77)
|
||||
dump("[ctrl] TX login", buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||
ctrl.Write(buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||
cTracked++
|
||||
cInner++
|
||||
|
||||
var token uint32
|
||||
buf := make([]byte, 2048)
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for token == 0 && time.Now().Before(deadline) {
|
||||
p, ok := recv(ctrl, 200, buf)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
length, typ, _, _, _, _ := header(p)
|
||||
if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
|
||||
token = le.Uint32(p[0x1c:])
|
||||
errCode := le.Uint32(p[0x30:])
|
||||
if errCode != 0 || token == 0 {
|
||||
fmt.Printf(">> LOGIN REJECTED err=0x%08X token=0x%08X\n", errCode, token)
|
||||
return
|
||||
}
|
||||
fmt.Printf(">> LOGIN OK ✓ token=0x%08X\n", token)
|
||||
ctrl.Write(buildToken(cTracked, cInner, tokReq, cID, cRemote, token))
|
||||
cTracked++
|
||||
cInner++
|
||||
} else if typ == 0x07 {
|
||||
ctrl.Write(pingReply(p, cID, cRemote))
|
||||
}
|
||||
}
|
||||
if token == 0 {
|
||||
fmt.Println("no token — login not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
// Send conninfo on the control stream — routes the CI-V stream to this
|
||||
// authenticated session and announces our civ/audio local ports (50002/3).
|
||||
rigMAC := []byte{0x00, 0x90, 0xc7, 0x09, 0xba, 0x3f} // F6BGC's IC-7610 (from the caps packet)
|
||||
dump("[ctrl] TX conninfo", buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||
ctrl.Write(buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||
cTracked++
|
||||
cInner++
|
||||
// Let the rig's caps/conninfo replies flow for ~600ms (reply to pings).
|
||||
drainEnd := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(drainEnd) {
|
||||
if p, ok := recv(ctrl, 100, buf); ok {
|
||||
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||
ctrl.Write(pingReply(p, cID, cRemote))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== CI-V STREAM (50002) =====================
|
||||
// Bind our civ socket to LOCAL port 50002 (= the civport announced above),
|
||||
// as the Remote Utility does. Requires the Remote Utility to be CLOSED.
|
||||
civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, &net.UDPAddr{IP: net.ParseIP(ip), Port: 50002})
|
||||
if err != nil {
|
||||
fmt.Printf("dial civ (local :50002 — is the Remote Utility still running?): %v\n", err)
|
||||
return
|
||||
}
|
||||
defer civ.Close()
|
||||
vID := localID(civ)
|
||||
fmt.Printf("\n=== CI-V 50002 (myID=0x%08X) ===\n", vID)
|
||||
vRemote, ok := handshake(civ, vID, "civ")
|
||||
if !ok {
|
||||
fmt.Println("CI-V handshake failed (may need the conninfo packet on control first)")
|
||||
return
|
||||
}
|
||||
|
||||
var vTracked uint16 = 1 // outer tracked seq @0x06
|
||||
var vCivSeq uint16 = 1 // inner CI-V seq @0x13 (BE)
|
||||
// openClose(open) starts CI-V data flow.
|
||||
dump("[civ] TX openClose(open)", buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||
vTracked++
|
||||
vCivSeq++
|
||||
|
||||
// Try several read commands, spaced out. Some rigs NG the basic 0x03 read
|
||||
// over the network tunnel; 0x25 / 0x04 and unsolicited transceive frames
|
||||
// (sent when you turn the VFO) still work. The tunnel itself is proven, so
|
||||
// this figures out which read the rig actually answers.
|
||||
sendCiv := func(name string, f []byte) {
|
||||
fmt.Printf("[civ] TX %s\n", name)
|
||||
civ.Write(buildCivData(vTracked, vID, vRemote, vCivSeq, f))
|
||||
vTracked++
|
||||
vCivSeq++
|
||||
}
|
||||
// The rig is in STANDBY (network up, radio off) — it NG's every command
|
||||
// until powered on via CI-V. Send power-on (0x18 0x01, with an FE wake
|
||||
// preamble, as the Remote Utility does), then poll read-freq while it boots.
|
||||
powerOn := make([]byte, 0, 32)
|
||||
for i := 0; i < 25; i++ {
|
||||
powerOn = append(powerOn, 0xFE)
|
||||
}
|
||||
powerOn = append(powerOn, 0xFE, 0xFE, 0x98, 0xE0, 0x18, 0x01, 0xFD)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
sendCiv("POWER ON (0x18 01)", powerOn)
|
||||
fmt.Print("\n>>> rig booting (~10-15 s) — polling read-freq until it answers <<<\n\n")
|
||||
readFreq := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
|
||||
|
||||
cbuf := make([]byte, 4096)
|
||||
vbuf := make([]byte, 4096)
|
||||
end := time.Now().Add(30 * time.Second)
|
||||
lastIdleC, lastIdleV, lastCmd := time.Now(), time.Now(), time.Now()
|
||||
for time.Now().Before(end) {
|
||||
if p, ok := recv(ctrl, 40, cbuf); ok {
|
||||
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||
ctrl.Write(pingReply(p, cID, cRemote))
|
||||
}
|
||||
} else if time.Since(lastIdleC) > 200*time.Millisecond {
|
||||
ctrl.Write(ctrlPacket(0x00, 0, cID, cRemote))
|
||||
lastIdleC = time.Now()
|
||||
}
|
||||
if p, ok := recv(civ, 40, vbuf); ok {
|
||||
_, typ, _, _, _, _ := header(p)
|
||||
if typ == 0x07 {
|
||||
civ.Write(pingReply(p, vID, vRemote))
|
||||
} else if typ == 0x00 && len(p) > 0x15 && p[0x10] == 0xc1 {
|
||||
f := p[0x15:]
|
||||
if d := decodeCiv(f); d != "" {
|
||||
fmt.Printf(">> CI-V RX: % X %s\n", f, d)
|
||||
}
|
||||
}
|
||||
} else if time.Since(lastIdleV) > 200*time.Millisecond {
|
||||
civ.Write(ctrlPacket(0x00, 0, vID, vRemote))
|
||||
lastIdleV = time.Now()
|
||||
}
|
||||
if time.Since(lastCmd) > 1000*time.Millisecond {
|
||||
sendCiv("read-freq 0x03", readFreq)
|
||||
lastCmd = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Clean close.
|
||||
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x00)) // openClose(close)
|
||||
civ.Write(ctrlPacket(0x05, 0, vID, vRemote)) // disconnect
|
||||
ctrl.Write(ctrlPacket(0x05, 0, cID, cRemote))
|
||||
fmt.Println("\nDone. Look for '>> CI-V RX:' and 'FREQUENCY reply'.")
|
||||
}
|
||||
|
||||
// decodeCiv describes a received CI-V frame (FE FE <to> <from> <cmd> … FD).
|
||||
// Only frames FROM the rig (from=0x98) are interesting; our own echoed commands
|
||||
// (from=0xE0) return "" so they're not printed.
|
||||
func decodeCiv(f []byte) string {
|
||||
if len(f) < 6 || f[0] != 0xFE || f[1] != 0xFE {
|
||||
return ""
|
||||
}
|
||||
if f[3] != 0x98 { // not from the rig (our echoed command) — skip
|
||||
return ""
|
||||
}
|
||||
cmd := f[4]
|
||||
body := f[5 : len(f)-1] // between cmd and the trailing FD
|
||||
switch cmd {
|
||||
case 0xFA:
|
||||
return "NG (command rejected)"
|
||||
case 0xFB:
|
||||
return "OK (ack)"
|
||||
case 0x00, 0x03, 0x05: // (transceive) freq / read-freq
|
||||
if len(body) >= 5 {
|
||||
return "FREQ " + decodeFreq(body[:5])
|
||||
}
|
||||
case 0x25: // read/set VFO freq (body = subcmd + 5 BCD)
|
||||
if len(body) >= 6 {
|
||||
return fmt.Sprintf("VFO%d FREQ %s", body[0], decodeFreq(body[1:6]))
|
||||
}
|
||||
case 0x01, 0x04: // (transceive) mode / read-mode
|
||||
if len(body) >= 1 {
|
||||
return fmt.Sprintf("MODE 0x%02X filt 0x%02X", body[0], lastOr(body, 1))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("cmd 0x%02X", cmd)
|
||||
}
|
||||
|
||||
func lastOr(b []byte, i int) byte {
|
||||
if i < len(b) {
|
||||
return b[i]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// decodeFreq turns Icom little-endian BCD (5 bytes) into a MHz string.
|
||||
func decodeFreq(bcd []byte) string {
|
||||
var hz uint64
|
||||
mul := uint64(1)
|
||||
for _, b := range bcd {
|
||||
hz += uint64(b&0x0f) * mul
|
||||
mul *= 10
|
||||
hz += uint64(b>>4) * mul
|
||||
mul *= 10
|
||||
}
|
||||
return fmt.Sprintf("%.6f MHz", float64(hz)/1e6)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Command psttest probes PstRotatorAz's UDP tracking input to discover the wire
|
||||
// format it expects for the "DXLog.net" tracker. We don't have the spec, so this
|
||||
// sends a series of labelled candidate datagrams to the target and pauses between
|
||||
// each so you can watch PstRotatorAz and note which one it reacts to (the band
|
||||
// filter "Rotate Antenna only for the Selected Bands" changes, or the frequency
|
||||
// shows in the tracker box).
|
||||
//
|
||||
// Setup on the PstRotatorAz side:
|
||||
// - Tracker = DXLog.net
|
||||
// - Note the UDP port it listens on (commonly 12040) and this PC's IP
|
||||
// - In the tracker setup tick a couple of bands so the band filter is visible
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/psttest 192.168.1.50:12040 # default 14025.5 kHz
|
||||
// go run ./cmd/psttest 192.168.1.50:12040 21074 # a specific kHz
|
||||
// go run ./cmd/psttest 192.168.1.50:12040 21074 3 # repeat each variant 3x
|
||||
//
|
||||
// Watch PstRotatorAz as it runs; when one variant makes it react, that's the
|
||||
// format — tell me the variant number and I'll wire it into OpsLog.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type variant struct {
|
||||
name string
|
||||
// build returns the datagram for a given frequency in kHz.
|
||||
build func(khz float64) string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("usage: psttest <ip:port> [freqKHz] [repeat]")
|
||||
fmt.Println("example: psttest 192.168.1.50:12040 3525.5")
|
||||
os.Exit(2)
|
||||
}
|
||||
target := os.Args[1]
|
||||
khz := 3525.5
|
||||
if len(os.Args) >= 3 {
|
||||
if v, err := strconv.ParseFloat(os.Args[2], 64); err == nil {
|
||||
khz = v
|
||||
}
|
||||
}
|
||||
repeat := 1
|
||||
if len(os.Args) >= 4 {
|
||||
if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 {
|
||||
repeat = v
|
||||
}
|
||||
}
|
||||
|
||||
hz := int64(khz*1000 + 0.5)
|
||||
tensHz := hz / 10 // N1MM/DXLog <Freq> unit
|
||||
|
||||
// Tag <PST><FREQUENCY> is confirmed. PstRotator strips the decimal point and
|
||||
// reads the digits, so pin down the exact unit: watch which one shows the real
|
||||
// frequency (3.5255 MHz), not 0.35255 / 0.003525 / 0.035255.
|
||||
variants := []variant{
|
||||
{"FREQUENCY = Hz integer (expect 3.5255 MHz) ← prediction", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", hz) }},
|
||||
{"FREQUENCY = tens-of-Hz (expect 0.35255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensHz) }},
|
||||
{"FREQUENCY = kHz integer (expect 0.003525 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", int64(k+0.5)) }},
|
||||
{"FREQUENCY = kHz decimal (baseline: gave 0.035255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%.1f</FREQUENCY></PST>", k) }},
|
||||
}
|
||||
|
||||
conn, err := net.Dial("udp", target)
|
||||
if err != nil {
|
||||
fmt.Printf("dial %s: %v\n", target, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Printf("Sending %d variants to %s at %.1f kHz (%d Hz, %d tens-of-Hz), %dx each.\n\n", len(variants), target, khz, hz, tensHz, repeat)
|
||||
for i, v := range variants {
|
||||
msg := v.build(khz)
|
||||
fmt.Printf("── Variant %d: %s\n", i+1, v.name)
|
||||
fmt.Printf(" payload: %s\n", msg)
|
||||
for r := 0; r < repeat; r++ {
|
||||
if _, err := conn.Write([]byte(msg)); err != nil {
|
||||
fmt.Printf(" write error: %v\n", err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
fmt.Printf(" → sent. Watch PstRotatorAz for ~4s…\n\n")
|
||||
time.Sleep(4 * time.Second)
|
||||
}
|
||||
fmt.Println("Done. Which variant number made PstRotatorAz react?")
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/**
|
||||
* OpsLog multi-operator LIVE STATUS renderer.
|
||||
*
|
||||
* Reads the shared `live_status` table that every OpsLog instance heartbeats
|
||||
* (operator call + station call + freq/band/mode, refreshed every ~15s) and
|
||||
* shows the operators active in the last 2 minutes.
|
||||
*
|
||||
* Put this file on YOUR web server (the one reachable from the internet), point
|
||||
* it at the SAME MySQL database OpsLog uses for the shared logbook, and embed it
|
||||
* on the QRZ.com bio of the station call:
|
||||
*
|
||||
* <img src="https://your-server/tm74-status.php?img=1"> (image, cached ~min by QRZ)
|
||||
* or <a href="https://your-server/tm74-status.php">Live operators</a> (real-time page)
|
||||
*
|
||||
* QRZ strips <script>/<iframe>, so only an <img> auto-updates the page.
|
||||
*/
|
||||
|
||||
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||
$DB_PORT = 3306; // MySQL port — change if your server listens elsewhere
|
||||
$DB_NAME = 'opslog'; // database name
|
||||
$DB_USER = 'opslog';
|
||||
$DB_PASS = 'CHANGE_ME';
|
||||
$STALE_SECONDS = 120; // an operator is "active" if seen within this window
|
||||
|
||||
// PHP 8.1+ makes mysqli THROW on errors by default; turn that off so a missing
|
||||
// `live_status` table (not yet created by OpsLog) just yields an empty list
|
||||
// instead of a fatal "table doesn't exist".
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
|
||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT);
|
||||
if ($mysqli->connect_errno) {
|
||||
http_response_code(500);
|
||||
exit('DB error');
|
||||
}
|
||||
|
||||
// The table is created by OpsLog on first publish; tolerate it not existing yet.
|
||||
$rows = [];
|
||||
$sql = "SELECT operator, station, freq_hz, band, mode, updated_at
|
||||
FROM live_status
|
||||
WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND
|
||||
ORDER BY band, freq_hz";
|
||||
if ($stmt = @$mysqli->prepare($sql)) {
|
||||
$stmt->bind_param('i', $STALE_SECONDS);
|
||||
@$stmt->execute();
|
||||
if ($res = $stmt->get_result()) {
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
$station = $rows ? $rows[0]['station'] : 'OpsLog';
|
||||
$fmtFreq = function ($hz) { return $hz > 0 ? number_format($hz / 1e6, 3) . ' MHz' : '—'; };
|
||||
|
||||
// ── Image output (SVG) for the QRZ <img> embed: ?img=1 ──────────────────────
|
||||
if (isset($_GET['img'])) {
|
||||
header('Content-Type: image/svg+xml');
|
||||
header('Cache-Control: no-cache, max-age=30');
|
||||
$rowH = 26; $h = 44 + max(1, count($rows)) * $rowH; $w = 440;
|
||||
echo "<svg xmlns='http://www.w3.org/2000/svg' width='$w' height='$h' font-family='Segoe UI,Arial'>";
|
||||
echo "<rect width='$w' height='$h' rx='8' fill='#0f172a'/>";
|
||||
echo "<text x='14' y='26' fill='#38bdf8' font-size='15' font-weight='bold'>" . htmlspecialchars($station) . " — live operators</text>";
|
||||
$y = 44 + 18;
|
||||
if (!$rows) {
|
||||
echo "<text x='14' y='$y' fill='#94a3b8' font-size='13'>No operator active right now.</text>";
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$line = sprintf('%-10s %-4s %-9s %s', $r['operator'], $r['band'], $r['mode'], $fmtFreq($r['freq_hz']));
|
||||
echo "<text x='14' y='$y' fill='#e2e8f0' font-size='13' font-family='monospace'>" . htmlspecialchars($line) . "</text>";
|
||||
$y += $rowH;
|
||||
}
|
||||
echo "</svg>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── HTML page (real-time when opened directly; auto-refreshes every 20s) ─────
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?><!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<title><?= htmlspecialchars($station) ?> — live operators</title>
|
||||
<style>
|
||||
body { font-family: Segoe UI, Arial, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:16px; }
|
||||
h1 { color:#38bdf8; font-size:18px; margin:0 0 12px; }
|
||||
table { border-collapse:collapse; width:100%; max-width:560px; }
|
||||
th,td { text-align:left; padding:6px 12px; border-bottom:1px solid #1e293b; font-size:14px; }
|
||||
th { color:#94a3b8; text-transform:uppercase; font-size:11px; letter-spacing:.05em; }
|
||||
td.mono { font-family:monospace; }
|
||||
.none { color:#94a3b8; }
|
||||
</style></head><body>
|
||||
<h1><?= htmlspecialchars($station) ?> — operators active now</h1>
|
||||
<?php if (!$rows): ?>
|
||||
<p class="none">No operator active right now.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Operator</th><th>Band</th><th>Mode</th><th>Frequency</th></tr>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($r['operator']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($r['band']) ?></td>
|
||||
<td><?= htmlspecialchars($r['mode']) ?></td>
|
||||
<td class="mono"><?= $fmtFreq($r['freq_hz']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</body></html>
|
||||
Generated
+121
-1
@@ -23,16 +23,23 @@
|
||||
"ag-grid-react": "^35.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-geo": "^3.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -2577,6 +2584,16 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2584,6 +2601,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
@@ -2622,6 +2656,27 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-client": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*",
|
||||
"@types/topojson-specification": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-specification": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -2773,6 +2828,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2787,6 +2848,30 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2953,6 +3038,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -2995,6 +3089,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -3634,6 +3734,20 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -3820,6 +3934,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/world-atlas": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -24,16 +24,23 @@
|
||||
"ag-grid-react": "^35.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-geo": "^3.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
687705a933fcf09f20bdb5083955a417
|
||||
f9b41e192918fa2511f68cd1b361fcd3
|
||||
+3729
-764
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
import { ADIFFields } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldDef = {
|
||||
name: string; kind: string; category: string;
|
||||
promoted: boolean; deprecated: boolean; intl: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
// The QSO's extras map (uppercase ADIF tag → raw value).
|
||||
value: Record<string, string> | undefined;
|
||||
onChange: (next: Record<string, string> | undefined) => void;
|
||||
}
|
||||
|
||||
// AdifExtrasEditor — a dictionary-driven editor for every ADIF field that is
|
||||
// NOT promoted to a first-class column. Backed by the QSO's extras map, so any
|
||||
// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is
|
||||
// what makes "100% of ADIF fields available" true without 160 DB columns.
|
||||
export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [dict, setDict] = useState<FieldDef[]>([]);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
ADIFFields().then((f) => setDict((f ?? []) as any)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Entries currently set, sorted for stable display.
|
||||
const entries = useMemo(
|
||||
() => Object.entries(value ?? {}).sort((a, b) => a[0].localeCompare(b[0])),
|
||||
[value],
|
||||
);
|
||||
|
||||
// Addable fields: standard non-promoted ADIF fields not already present.
|
||||
// Deprecated (import-only) fields are hidden unless the toggle is on.
|
||||
const addable = useMemo(() => {
|
||||
const have = new Set(Object.keys(value ?? {}));
|
||||
return dict
|
||||
.filter((f) => !f.promoted && !have.has(f.name) && (showDeprecated || !f.deprecated))
|
||||
.map((f) => f.name);
|
||||
}, [dict, value, showDeprecated]);
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const m: Record<string, FieldDef> = {};
|
||||
for (const f of dict) m[f.name] = f;
|
||||
return m;
|
||||
}, [dict]);
|
||||
|
||||
function setKV(key: string, val: string) {
|
||||
const next = { ...(value ?? {}) };
|
||||
next[key] = val;
|
||||
onChange(next);
|
||||
}
|
||||
function remove(key: string) {
|
||||
const next = { ...(value ?? {}) };
|
||||
delete next[key];
|
||||
onChange(Object.keys(next).length ? next : undefined);
|
||||
}
|
||||
function addField(name: string) {
|
||||
const key = name.trim().toUpperCase();
|
||||
if (!key || (value && key in value)) return;
|
||||
setKV(key, '');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('adx.introPart1')}<code className="bg-muted px-1 rounded font-mono">APP_*</code>{t('adx.introPart2')}<strong>{t('adx.fullMode')}</strong>{t('adx.introPart3')}
|
||||
</p>
|
||||
|
||||
{/* Add a field */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 max-w-xs">
|
||||
<Combobox
|
||||
value=""
|
||||
options={addable}
|
||||
placeholder={t('adx.addFieldPh')}
|
||||
allowFreeText
|
||||
onChange={addField}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
|
||||
{t('adx.showDeprecated')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Current entries */}
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
|
||||
{t('adx.noExtra')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{entries.map(([k, v]) => {
|
||||
const def = meta[k];
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-2">
|
||||
<div className="w-48 shrink-0">
|
||||
<span className="font-mono text-xs font-semibold">{k}</span>
|
||||
{def && (
|
||||
<span className="block text-[10px] text-muted-foreground leading-tight">
|
||||
{def.category}{def.deprecated ? ' · ' + t('adx.deprecated') : ''}{def.intl ? ' · ' + t('adx.intl') : ''}
|
||||
{!def && ''}
|
||||
</span>
|
||||
)}
|
||||
{!def && <span className="block text-[10px] text-warning leading-tight">{t('adx.nonStandard')}</span>}
|
||||
</div>
|
||||
<Input
|
||||
className="flex-1 h-8 text-xs font-mono"
|
||||
value={v}
|
||||
onChange={(e) => setKV(k, e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(k)} title={t('adx.removeField')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
|
||||
} from '@/../wailsjs/go/main/App';
|
||||
import { alerts } from '@/../wailsjs/go/models';
|
||||
|
||||
type Rule = alerts.Rule;
|
||||
|
||||
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
|
||||
|
||||
function emptyRule(): Rule {
|
||||
return alerts.Rule.createFrom({
|
||||
id: '', name: 'New alert', enabled: true,
|
||||
calls: [], countries: [], continents: [], bands: [], modes: [],
|
||||
spotter_call: '', spotter_continents: [], spotter_countries: [],
|
||||
sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false,
|
||||
});
|
||||
}
|
||||
|
||||
// MultiCheck — a scrollable checkbox list for a set of options with an optional
|
||||
// search box (used for the long country list). Empty selection = ALL.
|
||||
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
|
||||
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState('');
|
||||
const shown = useMemo(
|
||||
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
|
||||
[options, q],
|
||||
);
|
||||
const sel = new Set((selected ?? []).map((s) => s.toLowerCase()));
|
||||
return (
|
||||
<div className="rounded-md border border-border">
|
||||
{searchable && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
|
||||
<Search className="size-3 text-muted-foreground" />
|
||||
<input className="flex-1 bg-transparent text-xs outline-none" placeholder={t('altm.filterPh')} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('overflow-y-auto p-1', height)}>
|
||||
{shown.map((o) => (
|
||||
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
|
||||
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
|
||||
<span className="truncate">{o}</span>
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">{t('altm.noMatch')}</div>}
|
||||
</div>
|
||||
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
|
||||
{(selected?.length ?? 0) === 0 ? t('altm.noneAll') : t('altm.nSelected', { n: selected!.length })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [draft, setDraft] = useState<Rule | null>(null);
|
||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||
const [emailTo, setEmailTo] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try { setRules(((await ListAlertRules()) ?? []) as Rule[]); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||
|
||||
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||
if (!d) return d;
|
||||
const cur = ((d as any)[key] as string[]) ?? [];
|
||||
const has = cur.some((x) => x.toLowerCase() === v.toLowerCase());
|
||||
const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v];
|
||||
return alerts.Rule.createFrom({ ...d, [key]: next });
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function del() {
|
||||
if (!draft) return;
|
||||
if (!draft.id) { setDraft(null); return; }
|
||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
|
||||
{/* Rule list */}
|
||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||
{rules.map((r) => (
|
||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||
<span className="truncate flex-1">{r.name}</span>
|
||||
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
||||
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-2 py-1.5 border-t border-border/60">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('altm.emailTo')}</Label>
|
||||
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
|
||||
onChange={(e) => setEmailTo(e.target.value)}
|
||||
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
|
||||
{!draft ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">{t('altm.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-2 pt-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="def">{t('altm.tabDef')}</TabsTrigger>
|
||||
<TabsTrigger value="call">{t('altm.tabCall')}</TabsTrigger>
|
||||
<TabsTrigger value="bm">{t('altm.tabBandMode')}</TabsTrigger>
|
||||
<TabsTrigger value="orig">{t('altm.tabOrigin')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
<TabsContent value="def" className="mt-0 space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">{t('altm.ruleName')}</Label>
|
||||
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> {t('altm.alertEnabled')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-40 shrink-0">{t('altm.againAfter')}</Label>
|
||||
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
|
||||
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
|
||||
<span className="text-[11px] text-muted-foreground">{t('altm.againHint')}</span>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<Label className="text-xs font-semibold">{t('altm.actions')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> {t('altm.visual')}</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> {t('altm.sound')}</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> {t('altm.email')}</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> {t('altm.skipWorked')}
|
||||
</label>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
||||
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||
value={(draft.calls ?? []).join('\n')}
|
||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label className="text-xs">{t('altm.continents')}</Label>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{CONTINENTS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.bands')}</Label>
|
||||
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.modes')}</Label>
|
||||
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.spotterCall')}</Label>
|
||||
<Input className="font-mono" placeholder={t('altm.spotterCallPh')} value={draft.spotter_call ?? ''}
|
||||
onChange={(e) => patch({ spotter_call: e.target.value })} />
|
||||
<Label className="text-xs mt-2 block">{t('altm.spotterContinents')}</Label>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{CONTINENTS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.spotterCountries')}</Label>
|
||||
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* Editor actions */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-5 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> {t('altm.close')}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { Antenna, X, ListFilter } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type AGAntenna = { index: number; name: string; bands?: number };
|
||||
export type AGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||
antennas: AGAntenna[];
|
||||
};
|
||||
|
||||
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
|
||||
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
|
||||
// configured for the current band, like the native app. If the mapping ever
|
||||
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
|
||||
// device's actual mask so it can be corrected.
|
||||
const AG_BAND_BITS: Record<string, number> = {
|
||||
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
|
||||
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
|
||||
};
|
||||
|
||||
function bandBit(band?: string): number {
|
||||
if (!band) return 0;
|
||||
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
|
||||
}
|
||||
|
||||
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||
function pretty(name: string): string {
|
||||
const t = name.trim();
|
||||
if (!t) return t;
|
||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||
// Clicking an already-selected port deselects it (port → None).
|
||||
export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
status: AGStatus;
|
||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||
onClose: () => void;
|
||||
band?: string; // current operating band (e.g. "20m") for the band filter
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const allAntennas = status.antennas ?? [];
|
||||
|
||||
// Band filter (persisted, default ON): show only antennas whose band mask
|
||||
// includes the current band — matching the native 4O3A app. Antennas with an
|
||||
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
|
||||
// band we can't map, or masks the device didn't send) we fall back to the full
|
||||
// list so the panel is never mysteriously empty.
|
||||
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
|
||||
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
|
||||
const bit = bandBit(band);
|
||||
let list = allAntennas;
|
||||
if (bandFilter && bit !== 0) {
|
||||
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
|
||||
if (filtered.length > 0) list = filtered;
|
||||
}
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={toggleBandFilter}
|
||||
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
|
||||
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
|
||||
<ListFilter className="size-3.5" />
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||
{!status.connected ? (
|
||||
<div className="text-center py-6 text-xs space-y-2">
|
||||
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
|
||||
{status.last_error && <div className="text-danger font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
|
||||
) : list.map((a) => {
|
||||
const aActive = status.port_a === a.index;
|
||||
const bActive = status.port_b === a.index;
|
||||
const aTx = aActive && !!status.tx_a;
|
||||
const bTx = bActive && !!status.tx_b;
|
||||
const nameCls = (aTx || bTx)
|
||||
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
|
||||
: aActive
|
||||
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
|
||||
: bActive
|
||||
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
|
||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||
return (
|
||||
<div key={a.index} className="flex items-center gap-1.5">
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||
{pretty(a.name)}
|
||||
</div>
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
|
||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
// Above this many references the editor stops loading the whole list and
|
||||
// switches to search-only (mirrors Log4OM's "Too many items" behaviour).
|
||||
const REF_LIST_CAP = 1000;
|
||||
|
||||
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
|
||||
|
||||
export type AwardDef = {
|
||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
ref_display?: string; // grid column shows: ref | name | both
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
total: number; builtin?: boolean;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
|
||||
leading_str?: string; trailing_str?: string; prefix?: string;
|
||||
};
|
||||
|
||||
type AwardRef = {
|
||||
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
||||
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
||||
score?: number; bonus?: number; gridsquare?: string; alias?: string;
|
||||
};
|
||||
|
||||
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
|
||||
|
||||
// Award types mirror Log4OM: REFERENCE (we supply a list), QSOFIELDS (scan any
|
||||
// QSO field — handles state/grid4/zones/etc.), CALLSIGN. The type is
|
||||
// organizational only; matching is driven by the field/pattern/dynamic options,
|
||||
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
|
||||
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
||||
const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||
|
||||
const emptyAward = (): AwardDef => ({
|
||||
code: 'NEW', name: 'New award', description: '', valid: true,
|
||||
type: 'QSOFIELDS', field: 'note', match_by: 'code', exact_match: true, pattern: '',
|
||||
dxcc_filter: null, confirm: ['lotw', 'qsl'], validate: ['lotw', 'qsl'], total: 0,
|
||||
});
|
||||
|
||||
interface Props { open: boolean; onClose: () => void; onSaved: () => void; }
|
||||
|
||||
// Small reusable multi-toggle chip group.
|
||||
function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onToggle: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{all.map((v) => {
|
||||
const on = value.includes(v);
|
||||
return (
|
||||
<button key={v} type="button" onClick={() => onToggle(v)}
|
||||
className={cn('px-2 py-0.5 rounded text-[11px] border', on
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background border-border text-muted-foreground hover:bg-accent')}>
|
||||
{v}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DxccFilter — pick the entities an award is scoped to by country name (like the
|
||||
// QSO editor), resolving each to its ADIF DXCC number. Stored as number[].
|
||||
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
|
||||
const { t } = useI18n();
|
||||
const [names, setNames] = useState<Record<number, string>>({});
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
const miss = value.filter((n) => names[n] === undefined);
|
||||
if (miss.length === 0) return;
|
||||
const got: Record<number, string> = {};
|
||||
for (const n of miss) { try { got[n] = await DXCCName(n); } catch { got[n] = ''; } }
|
||||
if (live) setNames((m) => ({ ...m, ...got }));
|
||||
})();
|
||||
return () => { live = false; };
|
||||
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function addCountry(name: string) {
|
||||
const n = await DXCCForCountry(name);
|
||||
if (n && n > 0 && !value.includes(n)) {
|
||||
setNames((m) => ({ ...m, [n]: name }));
|
||||
onChange([...value, n]);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Combobox value="" options={countries} placeholder={t('awed.addCountry')} onChange={addCountry} className="h-8 w-full" />
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((n) => (
|
||||
<span key={n} className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-accent border border-border">
|
||||
<span className="font-mono">#{n}</span>
|
||||
<span className="text-muted-foreground">{names[n] || '…'}</span>
|
||||
<button className="hover:text-destructive" onClick={() => onChange(value.filter((x) => x !== n))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [fields, setFields] = useState<string[]>([]);
|
||||
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
||||
const [presets, setPresets] = useState<Preset[]>([]);
|
||||
const [countries, setCountries] = useState<string[]>([]);
|
||||
const [sel, setSel] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
// The err banner doubles as a success/notice area (export path, import counts,
|
||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||
// forever; the longer text (export path) gets a bit more time.
|
||||
useEffect(() => {
|
||||
if (!err) return;
|
||||
const t = window.setTimeout(() => setErr(''), 8000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [err]);
|
||||
|
||||
const loadMeta = () => GetAwardReferenceMeta()
|
||||
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setErr('');
|
||||
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
||||
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
||||
.catch((e) => setErr(String(e?.message ?? e)));
|
||||
loadMeta();
|
||||
}, [open]);
|
||||
|
||||
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
|
||||
// here exists only on this machine — it is yours alone, and a reinstall loses it
|
||||
// unless it has been exported. The list flags those.
|
||||
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||
}, [open]);
|
||||
|
||||
// Shipped fixes we did NOT apply, because this award carries the operator's own
|
||||
// changes and we will not overwrite those behind their back. Offered, not forced.
|
||||
type AwardUpdate = { code: string; name: string; from: number; to: number };
|
||||
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
|
||||
const loadUpdates = useCallback(() => {
|
||||
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
|
||||
|
||||
// Pending import awaiting the operator's decision on the awards that collide.
|
||||
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||
const [decisions, setDecisions] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!importPreview) return;
|
||||
// Default to the SAFE choice: keep what you have. An import must never destroy
|
||||
// an award because the operator clicked through a dialog without reading it.
|
||||
const d: Record<string, string> = {};
|
||||
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
|
||||
setDecisions(d);
|
||||
}, [importPreview]);
|
||||
|
||||
const cur = defs[sel];
|
||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||
|
||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||
type Rejected = { candidate: string; reason: string };
|
||||
type Step = {
|
||||
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
|
||||
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
|
||||
skipped?: boolean; error?: string;
|
||||
};
|
||||
type Explanation = {
|
||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||
};
|
||||
type TestRow = { qso: any; explanation: Explanation };
|
||||
const [testCall, setTestCall] = useState('');
|
||||
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
|
||||
const [testErr, setTestErr] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const runTest = async () => {
|
||||
if (!cur) return;
|
||||
setTesting(true); setTestErr(''); setTestRows(null);
|
||||
try {
|
||||
const r = await ExplainAward(cur.code, testCall);
|
||||
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
|
||||
} catch (e: any) {
|
||||
setTestErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
|
||||
// so, rather than let the operator test a rule they only think they applied.
|
||||
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
|
||||
|
||||
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
|
||||
// of the same trace is noise. But they don't always: scope is judged per QSO
|
||||
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
|
||||
// two contacts can even hold different QTHs. That divergence is often the actual
|
||||
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
|
||||
// trace instead of dropping it: one card per distinct outcome, with its count.
|
||||
const testGroups = useMemo(() => {
|
||||
if (!testRows) return null;
|
||||
const groups: { key: string; rows: TestRow[] }[] = [];
|
||||
for (const row of testRows) {
|
||||
const key = JSON.stringify(row.explanation);
|
||||
const g = groups.find((x) => x.key === key);
|
||||
if (g) g.rows.push(row);
|
||||
else groups.push({ key, rows: [row] });
|
||||
}
|
||||
return groups;
|
||||
}, [testRows]);
|
||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||
const arr = ((cur?.[key] as string[]) ?? []);
|
||||
patch({ [key]: arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v] } as any);
|
||||
};
|
||||
|
||||
function addAward() {
|
||||
setDefs((ds) => [...ds, emptyAward()]);
|
||||
setSel(defs.length);
|
||||
}
|
||||
function removeAward(i: number) {
|
||||
setDefs((ds) => ds.filter((_, j) => j !== i));
|
||||
setSel((s) => Math.max(0, s >= i ? s - 1 : s));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setErr('');
|
||||
try {
|
||||
const clean = defs.filter((d) => d.code.trim())
|
||||
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [], validate: d.validate ?? [] }));
|
||||
await SaveAwardDefs(clean as any);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function reset() {
|
||||
try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Export every award definition + reference list to a JSON bundle (backup
|
||||
// that survives a reinstall / PC change, independent of the database).
|
||||
async function exportAwards() {
|
||||
setErr('');
|
||||
try {
|
||||
const p = await ExportAwards();
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import: LOOK FIRST, then ask.
|
||||
//
|
||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||
// someone shared and YOUR WAPC (its province list, its city regexes) was
|
||||
// destroyed without a word. Sharing awards is exactly what we want people to do,
|
||||
// so it must not be a data-loss trap.
|
||||
async function importAwards() {
|
||||
setErr('');
|
||||
try {
|
||||
const p: any = await InspectAwardImport();
|
||||
if (!p?.path) return; // cancelled
|
||||
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
|
||||
if (clashes.length === 0) {
|
||||
// Nothing collides — nothing to ask. This is the common case.
|
||||
const dec: Record<string, string> = {};
|
||||
for (const e of p.awards) dec[e.code] = 'replace';
|
||||
await applyImport(p.path, dec);
|
||||
return;
|
||||
}
|
||||
setImportPreview(p); // → the collision dialog decides
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function applyImport(path: string, decisions: Record<string, string>) {
|
||||
try {
|
||||
const r: any = await ApplyAwardImport(path, decisions);
|
||||
setImportPreview(null);
|
||||
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||
setDefs((d ?? []) as any); setSel(0);
|
||||
onSaved();
|
||||
if (r?.awards || r?.references) {
|
||||
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||
}
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateList(code: string) {
|
||||
setUpdating(code); setErr('');
|
||||
try { await UpdateAwardReferenceList(code); await loadMeta(); }
|
||||
catch (e: any) { setErr(`${code}: ${String(e?.message ?? e)}`); }
|
||||
finally { setUpdating(null); }
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toUpperCase();
|
||||
// Keep the original index `i` (used for selection/patch) but display the
|
||||
// list sorted alphabetically by code — scales as the catalogue grows.
|
||||
return defs.map((d, i) => ({ d, i }))
|
||||
.filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q))
|
||||
.sort((a, b) => a.d.code.localeCompare(b.d.code));
|
||||
}, [defs, search]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
{/* Left: award list */}
|
||||
<div className="border-r flex flex-col min-h-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input className="h-7 pl-7 text-xs" placeholder={t('awed.searchAwards')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filtered.map(({ d, i }) => {
|
||||
// An award that is NOT in the shipped catalog exists only in THIS
|
||||
// database: nobody else has it, and a reinstall loses it unless it
|
||||
// has been exported. That deserves to be visible at a glance, not
|
||||
// discovered the hard way.
|
||||
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||
// A pending update is only reachable from the award's own banner, so
|
||||
// the list has to say which award to open — otherwise the fix waits
|
||||
// behind a click nobody knows to make.
|
||||
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||
return (
|
||||
<button key={i} onClick={() => setSel(i)}
|
||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
{hasUpdate && (
|
||||
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
|
||||
<ArrowUpCircle className="size-3.5 text-info" />
|
||||
</span>
|
||||
)}
|
||||
{onlyHere && (
|
||||
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||
title={t('awed.onlyHereTip')}>
|
||||
{t('awed.onlyHere')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||
apply it — that would destroy their work — so we offer it, and say
|
||||
plainly what accepting costs. */}
|
||||
{cur && selUpdate && (
|
||||
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="size-4 text-info shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{t('awed.updateAvailable')}</div>
|
||||
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
|
||||
</div>
|
||||
<Button size="sm" className="h-7 px-2 text-[11px]"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await ApplyAwardUpdate(cur.code);
|
||||
// The backend rewrote this award (and its references) — pull the
|
||||
// new state back, or the editor would keep showing, and on the
|
||||
// next Save re-persist, the definition we just replaced.
|
||||
setDefs(((await GetAwardDefs()) ?? []) as any);
|
||||
loadMeta();
|
||||
loadUpdates();
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}}>{t('awed.updateApply')}</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
|
||||
onClick={async () => {
|
||||
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}}>{t('awed.updateKeepMine')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/* ── Award info ── */}
|
||||
<TabsContent value="info" className="mt-0 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||
the catalog derives that on load. Asking the author to tick a
|
||||
box to declare it would be one more step nobody can guess —
|
||||
forget it and the award silently misses every future catalog
|
||||
correction. "Protected" stays: whether an award can be deleted
|
||||
IS a real choice. */}
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||
</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<Field2 label={t('awed.refDisplay')}>
|
||||
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
|
||||
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
|
||||
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Label className="text-xs text-muted-foreground pt-1.5">{t('awed.dxccFilter')}</Label>
|
||||
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validBands')}</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.emission')}</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validModes')}</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Award type ── */}
|
||||
<TabsContent value="type" className="mt-0 space-y-2.5">
|
||||
<Field2 label={t('awed.awardType')}>
|
||||
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{
|
||||
// Keep a legacy type (DXCC/GRID) selectable if an existing
|
||||
// award still uses it, so it isn't silently changed.
|
||||
(cur.type && !AWARD_TYPES.includes(cur.type) ? [cur.type, ...AWARD_TYPES] : AWARD_TYPES)
|
||||
.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)
|
||||
}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
{/* No "allow multiple references" switch: a QSO always counts for
|
||||
every reference its field holds (an n-fer POTA activation, a
|
||||
VUCC grid-line contact). The old checkbox was read by nothing. */}
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
||||
|
||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
||||
<Field2 label={t('awed.searchInField')}>
|
||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<Field2 label={t('awed.matchBy')}>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" name="matchby" checked={(cur.match_by || 'code') === m} onChange={() => patch({ match_by: m })} className="accent-primary" /> {m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
</div>
|
||||
|
||||
{/* Fallback searches: tried in order, only while nothing
|
||||
has matched yet — the first that hits wins (short-circuit),
|
||||
so a value already resolved by the primary rule isn't
|
||||
re-derived differently by a later one. */}
|
||||
<div className="border-t pt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||
<Button size="sm" variant="outline" className="h-7"
|
||||
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
||||
<Plus className="size-3.5" /> {t('awed.addOr')}
|
||||
</Button>
|
||||
</div>
|
||||
{(cur.or_rules ?? []).map((r, ri) => {
|
||||
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
|
||||
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
|
||||
return (
|
||||
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">{t('awed.orSearchIn')}</span>
|
||||
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> {t('awed.exact')}</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title={t('awed.removeOrSearch')}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder={t('awed.orPatternPlaceholder')} />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Confirmation ── */}
|
||||
<TabsContent value="conf" className="mt-0 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">{t('awed.confirmationLabel')}</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleIn('confirm', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">{t('awed.validationLabel')}</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.validate ?? []).includes(c.id)} onCheckedChange={() => toggleIn('validate', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||
did nothing at all. The stored values are kept (see award.Def);
|
||||
put the controls back when the export is actually wired up. */}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Test ──
|
||||
Runs the SAVED award against a real QSO and shows every rule:
|
||||
what it scanned, what it produced, and why each candidate was
|
||||
rejected. An award that matches nothing used to be a black box. */}
|
||||
<TabsContent value="test" className="mt-0 space-y-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||
{t('awed.testRun')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||
</div>
|
||||
|
||||
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||
|
||||
{testGroups?.map((g, ri) => {
|
||||
const row = g.rows[0];
|
||||
const ex = row.explanation;
|
||||
const matched = (ex.result ?? []).length > 0;
|
||||
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
|
||||
return (
|
||||
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||
{g.rows.length > 1 && (
|
||||
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!ex.in_scope ? (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{(ex.steps ?? []).map((s, si) => (
|
||||
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||
</span>
|
||||
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||
</div>
|
||||
{!s.skipped && !s.error && (
|
||||
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||
{s.field_value
|
||||
? <span className="font-mono">{s.field_value}</span>
|
||||
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||
</div>
|
||||
{(s.kept ?? []).map((k) => (
|
||||
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||
))}
|
||||
{(s.rejected ?? []).map((r, i2) => (
|
||||
<div key={i2} className="font-mono text-muted-foreground">
|
||||
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(ex.manual ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── References ── */}
|
||||
<TabsContent value="refs" className="mt-0">
|
||||
<ReferencesPanel
|
||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||
onChanged={loadMeta} setErr={setErr}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||
</Button>
|
||||
{/* The drop folder: put an award JSON here and it installs on restart —
|
||||
no rebuild, nobody to ask. Opening it beats printing a path someone
|
||||
then has to retype. */}
|
||||
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||
title={t('awed.awardsFolderTip')}>
|
||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* Collision dialog. The import cannot silently replace an award you already
|
||||
have — importing a shared WAPC used to destroy yours (province list, city
|
||||
regexes, band scope) without a word. You decide, per award. */}
|
||||
{importPreview && (
|
||||
<Dialog open onOpenChange={() => setImportPreview(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
|
||||
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
|
||||
{importPreview.awards.map((e) => (
|
||||
<div key={e.code} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="font-mono font-semibold text-sm">{e.code}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('awed.importRefs', { n: e.references })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!e.exists ? (
|
||||
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-[11px] text-warning-muted-foreground">
|
||||
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
|
||||
{e.protected && ` · ${t('awed.importProtected')}`}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{([
|
||||
['skip', t('awed.importKeepMine')],
|
||||
['replace', t('awed.importReplace')],
|
||||
['copy', t('awed.importCopy', { code: e.code })],
|
||||
] as [string, string][]).map(([v, label]) => (
|
||||
<button key={v} type="button"
|
||||
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
|
||||
className={cn('px-2 h-7 rounded-md border text-xs',
|
||||
decisions[e.code] === v
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={() => applyImport(importPreview.path, decisions)}>
|
||||
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||
// the online updater (POTA/SOTA/WWFF).
|
||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
||||
code: string; presets: Preset[]; meta?: RefMeta;
|
||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [selCode, setSelCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [bulk, setBulk] = useState('');
|
||||
const [showBulk, setShowBulk] = useState(false);
|
||||
const [hasBuiltin, setHasBuiltin] = useState(false);
|
||||
|
||||
const total = meta?.count ?? 0;
|
||||
const large = total > REF_LIST_CAP;
|
||||
|
||||
// Small lists are loaded whole and filtered client-side; large lists (POTA,
|
||||
// 85k parks) are search-only to stay responsive.
|
||||
const load = () => {
|
||||
if (!code) return;
|
||||
if (total > REF_LIST_CAP) { setRefs([]); return; }
|
||||
setBusy(true);
|
||||
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
|
||||
};
|
||||
useEffect(load, [code, total]);
|
||||
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
|
||||
|
||||
// Server-side search for large lists (debounced, min 2 chars).
|
||||
useEffect(() => {
|
||||
if (!large) return;
|
||||
const s = q.trim();
|
||||
if (s.length < 2) { setRefs([]); return; }
|
||||
const t = window.setTimeout(() => {
|
||||
setBusy(true);
|
||||
SearchAwardReferences(code, s, 0, 200).then((r) => setRefs((r ?? []) as any)).catch(() => setRefs([])).finally(() => setBusy(false));
|
||||
}, 200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [code, q, large]);
|
||||
|
||||
async function populateBuiltin() {
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(t('awed.populatedMsg', { n })); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const sel = refs.find((r) => r.code === selCode) || null;
|
||||
// Large lists are already filtered by the server; small lists filter locally.
|
||||
const filtered = useMemo(() => {
|
||||
if (large) return refs;
|
||||
const s = q.trim().toUpperCase();
|
||||
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
|
||||
}, [refs, q, large]);
|
||||
|
||||
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
|
||||
|
||||
async function saveRef(r: AwardRef) {
|
||||
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function addRef() {
|
||||
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||
if (!c) return;
|
||||
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
||||
await saveRef(r); setSelCode(c);
|
||||
}
|
||||
async function delRef(c: string) {
|
||||
try { await DeleteAwardReference(code, c); setSelCode(null); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function applyPreset(key: string) {
|
||||
if (!key) return;
|
||||
try { await ApplyAwardPreset(code, key); load(); onChanged(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function importBulk() {
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(t('awed.importedRefsMsg', { n })); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('awed.referenceCount')} <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
|
||||
<div className="flex-1" />
|
||||
<Select value="" onValueChange={applyPreset}>
|
||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder={t('awed.applyPreset')} /></SelectTrigger>
|
||||
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>{t('awed.pasteCsv')}</Button>
|
||||
{hasBuiltin && (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title={t('awed.populateBuiltinTitle')}>
|
||||
{t('awed.populateBuiltin')}
|
||||
</Button>
|
||||
)}
|
||||
{meta?.can_update && (
|
||||
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
|
||||
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} {t('awed.updateOnline')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> {t('awed.add')}</Button>
|
||||
</div>
|
||||
|
||||
{showBulk && (
|
||||
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.onePerLine')} <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> {t('awed.replacesList')}</p>
|
||||
<Textarea rows={6} className="font-mono text-xs" value={bulk} onChange={(e) => setBulk(e.target.value)} placeholder={'ON,Ontario,,,1\nQC,Quebec,,,1'} />
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>{t('awed.cancel')}</Button><Button size="sm" className="h-7" onClick={importBulk}>{t('awed.import2')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[200px_1fr] gap-3">
|
||||
{/* List */}
|
||||
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder={t('awed.search')} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
|
||||
{!busy && large && q.trim().length < 2 && (
|
||||
<div className="m-2 rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground">
|
||||
{t('awed.tooManyItems', { total: total.toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('awed.noReferences')}</div>}
|
||||
{filtered.map((r) => (
|
||||
<button key={r.code} onClick={() => setSelCode(r.code)}
|
||||
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
|
||||
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
||||
<span className="text-muted-foreground truncate">{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-reference editor */}
|
||||
<div className="border rounded p-3">
|
||||
{!sel ? (
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">{t('awed.selectReference')}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
<div className="flex-1" />
|
||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
{/* One per row: side by side, each half spends 120px of its width on
|
||||
the label column and the input is left too narrow to read a group
|
||||
name ("Basilicata" → "Basili"). */}
|
||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||
{/* Score / Bonus were here. Nothing computes an award score, so both
|
||||
boxes were pure decoration. The columns stay in the database — a
|
||||
third-party list may carry the values — but they are not offered
|
||||
for editing until something actually reads them. */}
|
||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
|
||||
interface Props {
|
||||
code: string; // award code, e.g. "POTA"
|
||||
label: string; // display label
|
||||
dxcc?: number; // contacted-station DXCC; used when "this country" is on
|
||||
countryOnly: boolean;
|
||||
value: string; // currently assigned reference
|
||||
onChange: (ref: string) => void;
|
||||
}
|
||||
|
||||
// AwardRefPicker — type-ahead search over an award's reference list (POTA parks,
|
||||
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
||||
// result assigns it to the QSO.
|
||||
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<Ref[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await SearchAwardReferences(code, q, countryOnly ? (dxcc ?? 0) : 0, 30);
|
||||
setResults((r ?? []) as any);
|
||||
} catch { setResults([]); }
|
||||
finally { setBusy(false); }
|
||||
}, 200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q, open, code, dxcc, countryOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||
window.addEventListener('mousedown', close);
|
||||
return () => window.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[60px_1fr] items-center gap-2">
|
||||
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
||||
<div className="relative" ref={boxRef}>
|
||||
{value ? (
|
||||
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-success-border bg-success-muted text-success-muted-foreground text-xs">
|
||||
<span className="font-mono font-semibold">{value}</span>
|
||||
<button className="ml-auto hover:text-success" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t('awrp.searchLabel', { label })}
|
||||
value={q}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
{open && !value && (
|
||||
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
|
||||
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('awrp.searching')}</div>}
|
||||
{!busy && results.length === 0 && (
|
||||
<div className="px-3 py-2 text-muted-foreground">{countryOnly && dxcc ? t('awrp.noMatchDxcc') : t('awrp.noMatch')}</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onChange(r.code); setOpen(false); setQ(''); }}
|
||||
>
|
||||
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
||||
<span className="text-muted-foreground truncate">{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X, Plus, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||
type Meta = { code: string; count: number; can_update: boolean };
|
||||
|
||||
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||
// are computed, never manually picked, so they don't belong in this picker.
|
||||
// Fields purely derived from the callsign / cty.dat — their awards are computed,
|
||||
// never picked. NB: 'state' and 'cnty' are NOT here — they're operator-settable
|
||||
// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE
|
||||
// pickable (a lookup rarely fills the JA prefecture or VE province).
|
||||
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid', 'grid4']);
|
||||
|
||||
// If DXCC-filtered auto-results exceed this, require the user to type instead.
|
||||
const AUTO_SHOW_MAX = 100;
|
||||
|
||||
interface Props {
|
||||
dxcc?: number;
|
||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
// Current QSO field values (state, cnty, …). When a predefined award reads one
|
||||
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
|
||||
// automatically — we surface that so the operator needn't pick it by hand.
|
||||
fieldValues?: Record<string, string>;
|
||||
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
|
||||
// the live entry panel passes "flex-1 min-h-0" so it fills the available
|
||||
// space instead of overflowing and forcing a scrollbar.
|
||||
heightClass?: string;
|
||||
}
|
||||
|
||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||
const [awardCode, setAwardCode] = useState('POTA');
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
// autoResults: loaded immediately when award/dxcc changes (empty query, DXCC-filtered).
|
||||
// Shown when q is short and count ≤ AUTO_SHOW_MAX (e.g. 5 IOTA refs for France).
|
||||
const [autoResults, setAutoResults] = useState<AwardRef[]>([]);
|
||||
// searchResults: loaded when user types 2+ chars.
|
||||
const [searchResults, setSearchResults] = useState<AwardRef[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selectedRef, setSelectedRef] = useState<AwardRef | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<string | null>(null);
|
||||
|
||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||
|
||||
// When the reference set is cleared (a QSO was logged or the call was reset),
|
||||
// also blank the picker's own state — the search box and the highlighted /
|
||||
// selected reference — so the Awards tab starts fresh for the next contact.
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedEntry(null);
|
||||
setSelectedRef(null);
|
||||
setQ('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||
.then(([d, m]) => {
|
||||
setDefs((d ?? []) as any);
|
||||
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// An award is offered when its DXCC scope matches the contacted entity (or it
|
||||
// has no scope) AND it has references to pick from (a loaded list, an online
|
||||
// list, or dynamic references like POTA). This is why DDFM (scope 227) shows
|
||||
// for a French call but not for others.
|
||||
const awards = useMemo(() => {
|
||||
return defs.filter((d) => {
|
||||
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||
const scope = d.dxcc_filter ?? [];
|
||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||
// award (e.g. "Worked All Provinces of China") has no built-in list, so
|
||||
// requiring one would make it impossible to assign references by hand. The
|
||||
// operator can pick from a loaded list when present, or add an unlisted
|
||||
// reference (the right-hand panel). Dynamic / list-backed awards behave as
|
||||
// before — they just always pass here now.
|
||||
return true;
|
||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
}, [defs, metas, dxcc]);
|
||||
|
||||
// Keep the selected award valid as the offered list changes with the call.
|
||||
useEffect(() => {
|
||||
if (awards.length === 0) return;
|
||||
if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
|
||||
}, [awards, awardCode]);
|
||||
|
||||
// Is the selected award a giant dynamic list (POTA/SOTA/IOTA)? Those carry a
|
||||
// per-reference DXCC so we filter by entity; predefined lists (WAS/RAC/WAJA)
|
||||
// are small and their refs may lack a per-ref DXCC, so we load them whole.
|
||||
const isDynamic = useMemo(
|
||||
() => !!defs.find((d) => d.code === awardCode)?.dynamic,
|
||||
[defs, awardCode],
|
||||
);
|
||||
// For dynamic lists, restrict to the contacted entity; otherwise load all.
|
||||
const refDxcc = isDynamic ? (dxcc ?? 0) : 0;
|
||||
|
||||
// The field the selected award reads (state / cnty / note / …).
|
||||
const selField = useMemo(
|
||||
() => String(defs.find((d) => d.code === awardCode)?.field ?? '').toLowerCase(),
|
||||
[defs, awardCode],
|
||||
);
|
||||
|
||||
// Search helper with a DXCC fallback: try the entity-scoped query first, but
|
||||
// if it finds nothing AND we were filtering by DXCC, retry unfiltered. This
|
||||
// fixes awards whose references carry no per-ref DXCC (e.g. SOTA summits,
|
||||
// where the country is in the summit prefix F/AB-001, not a DXCC column) —
|
||||
// otherwise filtering by entity returns zero. POTA/IOTA keep entity filtering
|
||||
// when their refs do match.
|
||||
const searchRefs = async (query: string, limit: number): Promise<AwardRef[]> => {
|
||||
let r = (await SearchAwardReferences(awardCode, query, refDxcc, limit)) as any as AwardRef[];
|
||||
if ((!r || r.length === 0) && refDxcc > 0) {
|
||||
r = (await SearchAwardReferences(awardCode, query, 0, limit)) as any as AwardRef[];
|
||||
}
|
||||
return r ?? [];
|
||||
};
|
||||
|
||||
// Auto-load refs on award/dxcc change with empty query. Fetches AUTO_SHOW_MAX+1
|
||||
// so we can distinguish "all results shown" from "too many to list".
|
||||
useEffect(() => {
|
||||
setAutoResults([]);
|
||||
// Dynamic lists need an entity to scope to; predefined lists load regardless.
|
||||
if (isDynamic && !dxcc) return;
|
||||
searchRefs('', AUTO_SHOW_MAX + 1)
|
||||
.then((r) => setAutoResults(r))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [awardCode, dxcc, isDynamic, refDxcc]);
|
||||
|
||||
// Typed search (2+ chars).
|
||||
useEffect(() => {
|
||||
if (q.length < 2) { setSearchResults([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setSearchResults(await searchRefs(q, 50));
|
||||
} catch { setSearchResults([]); }
|
||||
finally { setBusy(false); }
|
||||
}, 200);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [awardCode, q, refDxcc]);
|
||||
|
||||
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
|
||||
// When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results.
|
||||
const results: AwardRef[] = q.length >= 2 ? searchResults : (tooManyAuto ? [] : autoResults);
|
||||
|
||||
// Auto-match: when the selected award reads a QSO field that's already filled
|
||||
// (e.g. RAC reads `state`, and the call resolved to state NB), find the matching
|
||||
// reference so the operator can add it in one click — the award already counts
|
||||
// it from the field, but this makes it explicit and confirms it.
|
||||
const autoMatch = useMemo(() => {
|
||||
if (!selField || !fieldValues) return null;
|
||||
const v = String(fieldValues[selField] ?? '').trim().toUpperCase();
|
||||
if (!v) return null;
|
||||
const pool = (autoResults.length ? autoResults : searchResults);
|
||||
const m = pool.find((r) => r.code.toUpperCase() === v);
|
||||
return m ? { code: m.code, name: m.name } : null;
|
||||
}, [selField, fieldValues, autoResults, searchResults]);
|
||||
const autoAlreadyAdded = autoMatch ? entries.includes(`${awardCode}@${autoMatch.code}`) : false;
|
||||
|
||||
function addRef(ref: AwardRef) {
|
||||
const entry = `${awardCode}@${ref.code}`;
|
||||
if (!entries.includes(entry)) {
|
||||
onChange([...entries, entry].join(';'));
|
||||
}
|
||||
}
|
||||
|
||||
function removeEntry(entry: string) {
|
||||
const next = entries.filter((e) => e !== entry).join(';');
|
||||
onChange(next);
|
||||
if (selectedEntry === entry) setSelectedEntry(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex gap-2 ${heightClass}`}>
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
|
||||
<Select
|
||||
value={awardCode}
|
||||
onValueChange={(v) => { setAwardCode(v); setSelectedRef(null); setQ(''); setSearchResults([]); }}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{awards.map((a) => (
|
||||
<SelectItem key={a.code} value={a.code}>{a.code}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Group / Sub from selected ref */}
|
||||
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||
<span className="text-muted-foreground text-[11px]">{t('awrs.group')}</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
|
||||
<span className="text-muted-foreground text-[11px]">{t('awrs.sub')}</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* Selected ref chip */}
|
||||
{selectedRef ? (
|
||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||
<button className="ml-auto shrink-0 hover:text-success" onClick={() => setSelectedRef(null)}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
||||
{t('awrs.pickReference')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add — references are always scoped to the contacted DXCC */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={!selectedRef}
|
||||
onClick={() => selectedRef && addRef(selectedRef)}
|
||||
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="size-3" />{t('awrs.add')}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{dxcc ? `DXCC #${dxcc}` : t('awrs.enterCallsignFirst')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border shrink-0" />
|
||||
|
||||
{/* Added refs list */}
|
||||
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-0.5">{t('awrs.noRefsAdded')}</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry}
|
||||
className={`flex items-center gap-1.5 px-2 py-0.5 rounded text-xs cursor-pointer select-none ${
|
||||
selectedEntry === entry ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
||||
>
|
||||
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
|
||||
<button
|
||||
className="shrink-0 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: reference search */}
|
||||
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
|
||||
<span className="text-xs font-semibold">{t('awrs.references')}</span>
|
||||
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
||||
{autoMatch && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={autoAlreadyAdded}
|
||||
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
||||
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
||||
autoAlreadyAdded
|
||||
? 'border-success-border bg-success-muted text-success-muted-foreground cursor-default'
|
||||
: 'border-success-border bg-success-muted/60 text-success-muted-foreground hover:bg-success-muted'
|
||||
}`}
|
||||
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
||||
>
|
||||
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||
<span className="text-success-muted-foreground"> {t('awrs.fromField', { field: selField })}</span>
|
||||
{!autoAlreadyAdded && <span className="block text-[10px] text-success-muted-foreground/80">{t('awrs.autoClickToAdd')}</span>}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t('awrs.search')}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
{/* Add an UNLISTED reference: type a new code (e.g. a brand-new POTA park
|
||||
not yet in the list) and add it directly. */}
|
||||
{q.trim().length >= 2 && !results.some((r) => r.code.toUpperCase() === q.trim().toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
|
||||
className="text-left rounded border border-dashed border-primary/50 px-1.5 py-1 text-[11px] text-primary hover:bg-primary/10"
|
||||
title={t('awrs.addUnlistedTitle')}
|
||||
>
|
||||
{t('awrs.addPrefix')} <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||
<span className="text-[10px] text-muted-foreground"> {t('awrs.unlisted')}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
||||
{busy && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />{t('awrs.searching')}
|
||||
</div>
|
||||
)}
|
||||
{/* Too many auto-results → require typed search */}
|
||||
{!busy && q.length < 2 && tooManyAuto && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
{t('awrs.typeToSearch')}
|
||||
</div>
|
||||
)}
|
||||
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||
note the list is empty (predefined awards with no references). */}
|
||||
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
{isDynamic && !dxcc
|
||||
? t('awrs.enterCallsignOrSearch')
|
||||
: t('awrs.noRefsForEntity')}
|
||||
</div>
|
||||
)}
|
||||
{/* Typed search, no results */}
|
||||
{!busy && q.length >= 2 && results.length === 0 && (
|
||||
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
||||
{t('awrs.noResults')}
|
||||
<br />
|
||||
<span className="text-[10px]">{t('awrs.downloadLists')}</span>
|
||||
</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<div
|
||||
key={r.code}
|
||||
className={`px-2 py-1 cursor-pointer border-b border-border/30 last:border-0 ${
|
||||
selectedRef?.code === r.code ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => setSelectedRef(r)}
|
||||
onDoubleClick={() => { setSelectedRef(r); addRef(r); }}
|
||||
>
|
||||
<div className="font-mono font-semibold leading-tight text-[11px]">{r.code}</div>
|
||||
{r.name && (
|
||||
<div className="text-[10px] text-muted-foreground leading-tight truncate">{r.name}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
ref: string; name?: string; group?: string; subgrp?: string;
|
||||
worked: boolean; confirmed: boolean; validated: boolean;
|
||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
worked: number; confirmed: number; validated: number; total: number;
|
||||
bands: BandCount[]; refs: AwardRef[];
|
||||
};
|
||||
|
||||
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
||||
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
||||
|
||||
// Fixed band columns for the matrix view (Log4OM-style).
|
||||
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
||||
|
||||
// Per-band status for a reference, highest first.
|
||||
type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none';
|
||||
function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||
if (r.validated_bands?.includes(band)) return 'validated';
|
||||
if (r.confirmed_bands?.includes(band)) return 'confirmed';
|
||||
if (r.bands?.includes(band)) return 'worked';
|
||||
return 'none';
|
||||
}
|
||||
const CELL_STYLE: Record<CellStatus, string> = {
|
||||
validated: 'bg-success text-success-foreground',
|
||||
confirmed: 'bg-warning text-warning-foreground',
|
||||
worked: 'bg-muted-foreground text-background',
|
||||
none: '',
|
||||
};
|
||||
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
||||
|
||||
function pct(n: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(100, Math.round((n / total) * 100));
|
||||
}
|
||||
|
||||
// Two-segment progress: confirmed (solid green) over worked (light amber).
|
||||
function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: number; total: number }) {
|
||||
if (total <= 0) return null;
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
||||
<div className="bg-success" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||
<div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||
|
||||
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||
// Computed results are cached per award code — each award is scanned only the
|
||||
// first time it's selected (or when explicitly rescanned).
|
||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
const [refSearch, setRefSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||
const [showMissing, setShowMissing] = useState(false);
|
||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
// Bumped by Rescan to force the stats matrix to re-fetch (the selected award
|
||||
// didn't change, but the backend snapshot did).
|
||||
const [rescanTick, setRescanTick] = useState(0);
|
||||
|
||||
// Lazily fetch the statistics matrix when the Stats view is shown.
|
||||
useEffect(() => {
|
||||
if (view !== 'stats' || !selected) return;
|
||||
setStatsLoading(true);
|
||||
GetAwardStats(selected)
|
||||
.then((s) => setStats(s as any))
|
||||
.catch(() => setStats(null))
|
||||
.finally(() => setStatsLoading(false));
|
||||
}, [view, selected, rescanTick]);
|
||||
|
||||
// Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ
|
||||
// download are picked up) and the cached results, then recompute everything.
|
||||
async function rescan() {
|
||||
if (!selected) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await RescanAwards();
|
||||
setByCode({});
|
||||
setRescanTick((t) => t + 1);
|
||||
await compute(selected, true);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||
async function compute(code: string, force = false) {
|
||||
if (!code) return;
|
||||
if (!force && byCode[code]) { setSelected(code); return; }
|
||||
setSelected(code);
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = (await GetAward(code)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [code]: r }));
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load the award list (no QSO scan), then compute only the first award.
|
||||
async function loadList() {
|
||||
try {
|
||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||
const list: AwardListItem[] = defs
|
||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
setAwardList(list);
|
||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||
if (first) compute(first.code);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
}
|
||||
}
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
const current = byCode[selected];
|
||||
|
||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
|
||||
// the bands the operator actually has contacts on (so DXCC, which has no band
|
||||
// restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set =
|
||||
// no basis yet → callers fall back to all bands.
|
||||
const awardBands = useMemo(() => {
|
||||
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
||||
if (vb.length > 0) return new Set(vb);
|
||||
const worked = (current?.bands ?? [])
|
||||
.filter((b) => (b.worked ?? 0) > 0)
|
||||
.map((b) => String(b.band).toLowerCase());
|
||||
return new Set(worked);
|
||||
}, [awardList, selected, current]);
|
||||
|
||||
const gridBands = useMemo(() => {
|
||||
if (awardBands.size === 0) return GRID_BANDS;
|
||||
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
|
||||
return filtered.length ? filtered : GRID_BANDS;
|
||||
}, [awardBands]);
|
||||
|
||||
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
|
||||
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
|
||||
// shows only when the award has no emission restriction or lists that emission.
|
||||
const statsRows = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase()));
|
||||
if (em.size === 0) return stats.rows;
|
||||
return stats.rows.filter((r) => {
|
||||
const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/);
|
||||
return !m || em.has(m[1]);
|
||||
});
|
||||
}, [stats, awardList, selected]);
|
||||
|
||||
// Indices into stats.bands to display (and to slice each row's cells by), same
|
||||
// band basis as the grid.
|
||||
const statsBandIdx = useMemo(() => {
|
||||
if (!stats) return [] as number[];
|
||||
const all = stats.bands.map((_, i) => i);
|
||||
if (awardBands.size === 0) return all;
|
||||
const idx = stats.bands
|
||||
.map((b, i) => ({ b: b.toLowerCase(), i }))
|
||||
.filter((x) => awardBands.has(x.b))
|
||||
.map((x) => x.i);
|
||||
return idx.length ? idx : all;
|
||||
}, [stats, awardBands]);
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
return (current.refs ?? []).filter((r) => {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [current, refSearch, refFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Award list */}
|
||||
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<AwardIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">{t('awp.awards')}</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||
title={t('awp.rescanTitle')}>
|
||||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
{t('awp.rescan')}
|
||||
</Button>
|
||||
</div>
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
||||
{/* Quick selector — scales when there are many awards. */}
|
||||
{awardList.length > 0 && (
|
||||
<div className="px-2 py-2 border-b border-border/40">
|
||||
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
{awardList.map((a) => (
|
||||
<SelectItem key={a.code} value={a.code} className="text-xs">
|
||||
<span className="font-semibold">{a.code}</span>
|
||||
<span className="text-muted-foreground"> — {a.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||
{awardList.map((a) => {
|
||||
const r = byCode[a.code];
|
||||
return (
|
||||
<button
|
||||
key={a.code}
|
||||
onClick={() => { setRefSearch(''); compute(a.code); }}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
|
||||
selected === a.code && 'bg-accent/60',
|
||||
a.valid === false && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-semibold text-sm">{a.code}</span>
|
||||
{r ? (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
<span className="text-success">{r.confirmed}</span>
|
||||
/<span className="text-foreground">{r.worked}</span>
|
||||
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate mb-1">{a.name}</div>
|
||||
{r && <ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{!current ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{loading ? t('awp.computing') : t('awp.noData')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b border-border/60">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-lg font-bold">{current.code}</h3>
|
||||
<span className="text-sm text-muted-foreground">{current.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
|
||||
<span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
||||
<span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||
{current.total > 0 && (
|
||||
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
|
||||
</div>
|
||||
|
||||
{/* Band breakdown */}
|
||||
{(current.bands ?? []).length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border/60">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(current.bands ?? []).map((b) => (
|
||||
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||
<span className="text-success font-mono">{b.confirmed}</span>
|
||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* References toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setRefFilter(k)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||
title={t('awp.missingRefsTitle')}
|
||||
>
|
||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-muted-foreground" />W</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-warning" />C</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-success" />V</span>
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics matrix: status × band, by mode category */}
|
||||
{view === 'stats' ? (
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
{statsLoading || !stats ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
|
||||
) : (
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
||||
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsRows.map((row, i) => {
|
||||
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
|
||||
return (
|
||||
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
|
||||
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
||||
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||
); })}
|
||||
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
) : view === 'grid' ? (
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||
{gridBands.map((b) => (
|
||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||
</td>
|
||||
{gridBands.map((b) => {
|
||||
const s = cellStatus(r, b);
|
||||
return (
|
||||
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||
<button
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
||||
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
||||
: r.validated ? <span className="text-success">{t('awp.validated')}</span>
|
||||
: r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
|
||||
: <span className="text-muted-foreground">{t('awp.worked')}</span>}
|
||||
</td>
|
||||
<td className="py-1 font-mono text-muted-foreground">
|
||||
{r.bands.map((b) => (
|
||||
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-success font-semibold' : r.confirmed_bands.includes(b) && 'text-warning font-semibold')}>{b}</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cell && current && (
|
||||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||||
)}
|
||||
{showMissing && current && (
|
||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MissingQSOModal lists contacts within an award's scope that carry NO
|
||||
// reference — the silent gaps. Rows open the QSO editor so the operator can add
|
||||
// the missing reference (e.g. a department for DDFM).
|
||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||
|
||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||
const [assignRef, setAssignRef] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setSel(new Set());
|
||||
AwardMissingQSOs(code)
|
||||
.then((r) => setQsos((r ?? []) as any))
|
||||
.catch(() => setQsos([]))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(); }, [code]);
|
||||
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
||||
useEffect(() => {
|
||||
ListAwardReferences(code)
|
||||
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||
.catch(() => setRefs([]));
|
||||
}, [code]);
|
||||
|
||||
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
const val = (q: any): string => {
|
||||
switch (sortKey) {
|
||||
case 'qso_date': return String(q.qso_date ?? '');
|
||||
case 'callsign': return String(q.callsign ?? '').toUpperCase();
|
||||
case 'band': return String(q.band ?? '');
|
||||
case 'mode': return String(q.mode ?? '');
|
||||
case 'country': return String(q.country ?? '').toUpperCase();
|
||||
case 'qth': return qthOf(q).toUpperCase();
|
||||
}
|
||||
};
|
||||
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
|
||||
}, [qsos, sortKey, sortDir]);
|
||||
|
||||
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
|
||||
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
|
||||
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
|
||||
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const setSort = (k: MissingSortKey) => {
|
||||
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else { setSortKey(k); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
async function applyAssign() {
|
||||
if (!assignRef || sel.size === 0) return;
|
||||
setBusy(true); setMsg('');
|
||||
try {
|
||||
const assignedIds = Array.from(sel);
|
||||
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
|
||||
// Optimistic: the assigned contacts now carry a reference, so drop them
|
||||
// from the list locally instead of re-running the slow whole-log scan.
|
||||
const done = new Set(assignedIds);
|
||||
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||
setSel(new Set());
|
||||
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
|
||||
} catch (e: any) {
|
||||
setMsg(String(e?.message ?? e));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
|
||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||
|
||||
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
|
||||
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
|
||||
<span className="inline-flex items-center gap-0.5">{label}
|
||||
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||
<AlertTriangle className="size-4 text-warning" />
|
||||
<span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
|
||||
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
|
||||
title={t('awp.recomputeTitle')}>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
||||
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
|
||||
</div>
|
||||
|
||||
{/* Bulk-assign toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{refs.map((r) => (
|
||||
<SelectItem key={r.code} value={r.code}>
|
||||
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
||||
</Button>
|
||||
{msg && <span className="text-[11px] text-success">{msg}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
|
||||
) : qsos.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
{t('awp.noGaps')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||
<tr>
|
||||
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
|
||||
<SortTh k="callsign" label={t('awp.callsign')} />
|
||||
<SortTh k="band" label={t('awp.band')} />
|
||||
<SortTh k="mode" label={t('awp.mode')} />
|
||||
<SortTh k="country" label={t('awp.country')} />
|
||||
<SortTh k="qth" label={t('awp.qthNote')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((q, i) => {
|
||||
const id = q.id as number;
|
||||
return (
|
||||
<tr key={id ?? i}
|
||||
className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
|
||||
<td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
|
||||
</td>
|
||||
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
|
||||
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
|
||||
<td className="py-1 pr-2">{q.band}</td>
|
||||
<td className="py-1 pr-2">{q.mode}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
|
||||
{qsos.length} {t('awp.contactsWithoutRef')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
AwardCellQSOs(code, cell.ref, cell.band)
|
||||
.then((r) => setQsos((r ?? []) as any))
|
||||
.catch(() => setQsos([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code, cell.ref, cell.band]);
|
||||
|
||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||
<span className="font-semibold text-sm">{code} · <span className="font-mono">{cell.ref}</span> · {cell.band}</span>
|
||||
{cell.name && <span className="text-xs text-muted-foreground truncate">{cell.name}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
|
||||
) : qsos.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
||||
<tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qsos.map((q, i) => (
|
||||
<tr key={q.id ?? i} className="border-b border-border/30">
|
||||
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
||||
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
||||
<td className="py-1 pr-2">{q.band}</td>
|
||||
<td className="py-1 pr-2">{q.mode}</td>
|
||||
<td className="py-1 pr-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">{qsos.length} QSO{qsos.length > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { spotStatusKey, inferSpotMode } from '@/lib/spot';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
|
||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||
@@ -32,6 +33,14 @@ interface Props {
|
||||
currentFreqHz: number;
|
||||
onSpotClick: (s: Spot) => void;
|
||||
onClose?: () => void;
|
||||
side?: 'left' | 'right';
|
||||
onToggleSide?: () => void;
|
||||
// hideDigital drops every DATA-class (FT8/FT4/JS8/…) spot so the crowded
|
||||
// watering holes don't hog the map. fitToBand overrides zoom so the whole
|
||||
// band edge-to-edge fits the visible height (no scrolling). Both are driven
|
||||
// globally from the band-map tab toolbar.
|
||||
hideDigital?: boolean;
|
||||
fitToBand?: boolean;
|
||||
}
|
||||
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
@@ -52,19 +61,41 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
||||
};
|
||||
|
||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']],
|
||||
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']],
|
||||
'60m': [[5350, 5450, 'fill-amber-50']],
|
||||
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']],
|
||||
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']],
|
||||
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']],
|
||||
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']],
|
||||
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']],
|
||||
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']],
|
||||
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']],
|
||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn('size-2 rounded-full', cls)} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable label for a spot status — used in the pill hover tooltip
|
||||
// so the operator can see WHY a spot is coloured the way it is.
|
||||
function statusLabel(s: string, t: (k: string) => string): string {
|
||||
switch (s) {
|
||||
case 'new': return t('bmp.statusNew');
|
||||
case 'new-band': return t('bmp.statusNewBand');
|
||||
case 'new-slot': return t('bmp.statusNewSlot');
|
||||
case 'worked': return t('bmp.statusWorked');
|
||||
default: return t('bmp.statusUnresolved');
|
||||
}
|
||||
}
|
||||
|
||||
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
||||
// pill = full pill background+text+border
|
||||
// bar = thick left accent inside the pill
|
||||
@@ -72,22 +103,22 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
||||
// dot = small marker on the freq scale
|
||||
switch (s) {
|
||||
case 'new': return {
|
||||
pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100',
|
||||
bar: 'bg-rose-500',
|
||||
line: 'stroke-rose-400',
|
||||
dot: 'fill-rose-500',
|
||||
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||
bar: 'bg-danger',
|
||||
line: 'stroke-danger',
|
||||
dot: 'fill-danger',
|
||||
};
|
||||
case 'new-band': return {
|
||||
pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100',
|
||||
bar: 'bg-amber-500',
|
||||
line: 'stroke-amber-400',
|
||||
dot: 'fill-amber-500',
|
||||
pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
|
||||
bar: 'bg-warning',
|
||||
line: 'stroke-warning',
|
||||
dot: 'fill-warning',
|
||||
};
|
||||
case 'new-slot': return {
|
||||
pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100',
|
||||
bar: 'bg-yellow-500',
|
||||
line: 'stroke-yellow-500',
|
||||
dot: 'fill-yellow-500',
|
||||
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||||
bar: 'bg-caution',
|
||||
line: 'stroke-caution',
|
||||
dot: 'fill-caution',
|
||||
};
|
||||
case 'worked': return {
|
||||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
||||
@@ -114,11 +145,19 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
|
||||
const LABEL_W = 200;
|
||||
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
||||
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once.
|
||||
// These pile up on the watering-hole frequencies and otherwise spawn
|
||||
// hundreds of spots that fan out and cover the whole map. ONLY digital is
|
||||
// capped — CW and SSB are always shown in full. When more than this digital
|
||||
// spots are in band we keep the most useful (new entities first, worked
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
const [zoomIdx, setZoomIdx] = useState(0);
|
||||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerH, setContainerH] = useState(400);
|
||||
|
||||
@@ -134,7 +173,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
const fallback: [number, number] = range ?? [0, 1];
|
||||
const [lo, hi] = fallback;
|
||||
const span = hi - lo;
|
||||
const pxPerKHz = PX_PER_KHZ[zoomIdx];
|
||||
// Fit-to-band computes the exact px/kHz so the whole band edge-to-edge fills
|
||||
// the visible height; otherwise use the discrete zoom step.
|
||||
const fitPxPerKHz = fitToBand && containerH > 0 && span > 0
|
||||
? Math.max(0.05, (containerH - TOP_PAD - BOT_PAD) / span)
|
||||
: 0;
|
||||
const pxPerKHz = fitPxPerKHz || PX_PER_KHZ[zoomIdx];
|
||||
|
||||
// Anti-overlap layout: each label wants to sit at its true freq, but
|
||||
// never closer than PILL_H from the previous one. Sorted top-to-bottom
|
||||
@@ -146,19 +190,56 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
// larger). When more labels stack than fit in the band's natural pixel
|
||||
// span, totalH grows so scrolling reveals them.
|
||||
type Placed = { spot: Spot; freqY: number; labelY: number };
|
||||
const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => {
|
||||
const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
|
||||
// innerH is the band's stretched pixel span; total adds top+bottom
|
||||
// padding so the edge freq labels aren't clipped at y=0 / y=H.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD };
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
|
||||
const seen = new Set<string>();
|
||||
const filtered: Spot[] = [];
|
||||
const inBand: Spot[] = [];
|
||||
for (const s of spots) {
|
||||
if (s.freq_khz < lo || s.freq_khz > hi) continue;
|
||||
if (seen.has(s.dx_call)) continue;
|
||||
seen.add(s.dx_call);
|
||||
filtered.push(s);
|
||||
inBand.push(s);
|
||||
}
|
||||
|
||||
// Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…)
|
||||
// are capped — they're what floods the watering-hole frequencies. We key
|
||||
// off the mode CATEGORY (not a literal "FT8" string) because many FT8
|
||||
// spots carry no mode word and the band-plan fallback labels them the
|
||||
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
||||
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
||||
// hideDigital removes them entirely; otherwise they're capped below.
|
||||
const ftSpots = hideDigital ? [] : inBand.filter(isFlood);
|
||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||
|
||||
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
||||
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
||||
const rank = (s: Spot) => {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
switch (spotStatus[k]?.status ?? '') {
|
||||
case 'new': return 0;
|
||||
case 'new-band': return 1;
|
||||
case 'new-slot': return 2;
|
||||
case 'worked': return 4;
|
||||
default: return 3;
|
||||
}
|
||||
};
|
||||
let keptFt = ftSpots;
|
||||
let hiddenCount = 0;
|
||||
if (ftSpots.length > MAX_VISIBLE_SPOTS) {
|
||||
const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
|
||||
keptFt = [...ftSpots]
|
||||
.sort((a, b) => {
|
||||
const r = rank(a) - rank(b);
|
||||
if (r !== 0) return r;
|
||||
return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
|
||||
})
|
||||
.slice(0, MAX_VISIBLE_SPOTS);
|
||||
hiddenCount = ftSpots.length - keptFt.length;
|
||||
}
|
||||
const filtered = [...otherSpots, ...keptFt];
|
||||
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||
|
||||
// Desired pill-CENTRE Y for each spot = its true frequency's Y.
|
||||
@@ -203,17 +284,40 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
||||
}
|
||||
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
||||
// In fit mode the band scale exactly fills the viewport (innerH = containerH
|
||||
// − pads); when crowded sub-bands stack labels past the bottom the content
|
||||
// grows and a scroll bar appears — the map defaults to the top so the whole
|
||||
// band is visible, and you scroll down to reach the stacked spots.
|
||||
return {
|
||||
placed: out,
|
||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||
hidden: hiddenCount,
|
||||
};
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH]);
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz, hideDigital]);
|
||||
|
||||
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
||||
|
||||
// Keep the map centred on the rig frequency: the effect re-runs only when the
|
||||
// frequency actually changes (or band/zoom/size), so tuning follows the rig
|
||||
// while a stable frequency leaves your manual scroll alone.
|
||||
useEffect(() => {
|
||||
if (!range || containerH <= 0) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
// Fit-to-band: keep the view at the top so the full band is always in frame;
|
||||
// any overflow spots are reached by scrolling down (don't follow the rig).
|
||||
if (fitToBand) { el.scrollTop = 0; return; }
|
||||
if (currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
@@ -231,8 +335,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
if (!range) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||
<div className="text-sm font-semibold mb-1">Band map</div>
|
||||
Not configured for {band || '—'}.
|
||||
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
||||
{t('bmp.notConfigured', { band: band || '—' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -264,28 +368,35 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex items-center gap-1 shrink-0">
|
||||
<span className="flex-1">Map · {band}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom out">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title={t('bmp.zoomOut')}>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom in">
|
||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title={t('bmp.zoomIn')}>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
<button type="button" onClick={recenterOnRig}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Scroll to current rig frequency">
|
||||
title={t('bmp.scrollToRig')}>
|
||||
<Crosshair className="size-3" />
|
||||
</button>
|
||||
{onToggleSide && (
|
||||
<button type="button" onClick={onToggleSide}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title={side === 'right' ? t('bmp.moveLeft') : t('bmp.moveRight')}>
|
||||
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Hide band map">
|
||||
title={t('bmp.hide')}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -375,7 +486,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
{/* Pills absolutely positioned at their (anti-overlapped) Y */}
|
||||
{placed.map((p, i) => {
|
||||
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
|
||||
const st = spotStatus[k]?.status ?? '';
|
||||
const entry = spotStatus[k];
|
||||
const st = entry?.status ?? '';
|
||||
const style = statusStyle(st);
|
||||
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
|
||||
return (
|
||||
@@ -389,7 +501,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
style.pill,
|
||||
)}
|
||||
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
>
|
||||
{/* Status accent strip on the left */}
|
||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||
@@ -406,8 +518,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Colour legend — what each pill colour means. */}
|
||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||
{t('bmp.footerHint')}
|
||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,10 +11,22 @@ interface Props {
|
||||
busy: boolean;
|
||||
currentBand: string;
|
||||
currentMode: string;
|
||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||
}
|
||||
|
||||
// 13-column band layout — no 4m, no SHF (per user preference).
|
||||
const BANDS: { tag: string; label: string }[] = [
|
||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||
// strip the trailing "m" for meter bands (160m→160), and shorten cm bands
|
||||
// (13cm→13c) so the column stays narrow.
|
||||
function bandColLabel(tag: string): string {
|
||||
if (tag === '2m') return 'V';
|
||||
if (tag === '70cm') return 'U';
|
||||
if (tag.endsWith('cm')) return tag.replace('cm', 'c');
|
||||
return tag.replace(/m$/, '');
|
||||
}
|
||||
|
||||
// Default 13-column band layout, used when the operator hasn't configured bands.
|
||||
const DEFAULT_BANDS: { tag: string; label: string }[] = [
|
||||
{ tag: '160m', label: '160' },
|
||||
{ tag: '80m', label: '80' },
|
||||
{ tag: '60m', label: '60' },
|
||||
@@ -39,13 +51,29 @@ function classMatchesMode(cls: string, mode: string): boolean {
|
||||
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
||||
}
|
||||
|
||||
// Dedicated matrix palette (--mx-* tokens, per theme in style.css): a 3-level
|
||||
// ramp per hue so confirmed / worked / not-worked stay distinguishable on both
|
||||
// light and dark surfaces (the generic status -muted fills were too dark on the
|
||||
// dark themes to tell "worked" from "not worked" apart). Light-warm reproduces
|
||||
// the original emerald/indigo/stone colours exactly.
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
call_c: 'bg-emerald-700 hover:bg-emerald-600',
|
||||
call_w: 'bg-emerald-300 hover:bg-emerald-200',
|
||||
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
|
||||
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
||||
call_c: 'bg-mx-call-conf',
|
||||
call_w: 'bg-mx-call-work',
|
||||
dxcc_c: 'bg-mx-dx-conf',
|
||||
dxcc_w: 'bg-mx-dx-work',
|
||||
};
|
||||
|
||||
// Legend entries, in the same colour order as the cells. swatch = the
|
||||
// background class (or a special ring marker for the current-entry cell).
|
||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
||||
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
||||
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
||||
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
||||
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
||||
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
||||
];
|
||||
|
||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
||||
const desc =
|
||||
status === 'call_c' ? 'This callsign confirmed' :
|
||||
@@ -56,10 +84,17 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||
}
|
||||
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
||||
// Columns from the operator's configured bands (so the matrix shows only the
|
||||
// bands they actually use), falling back to the built-in default set.
|
||||
const cols = useMemo(
|
||||
() => (bands && bands.length ? bands.map((tag) => ({ tag, label: bandColLabel(tag) })) : DEFAULT_BANDS),
|
||||
[bands],
|
||||
);
|
||||
const dxcc = wb?.dxcc ?? 0;
|
||||
const dxccName = wb?.dxcc_name ?? '';
|
||||
const dxccCount = wb?.dxcc_count ?? 0;
|
||||
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||
const hasDxcc = dxcc > 0;
|
||||
const newOne = hasDxcc && dxccCount === 0;
|
||||
|
||||
@@ -71,22 +106,45 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
||||
return m;
|
||||
}, [wb]);
|
||||
|
||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||
// Derived straight from the entity's real band_status (all bands it was
|
||||
// worked on — not just the operator's configured column list).
|
||||
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
||||
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
||||
// lists every real (band, mode) the entity was worked on.
|
||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||
const curMode = (currentMode || '').toUpperCase().trim();
|
||||
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
||||
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||
// exact band+mode is NOT yet:
|
||||
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||
// New Band = the band is new (the mode was worked on another band).
|
||||
// New Mode = the mode is new (the band was worked in another mode).
|
||||
// New Slot = both band and mode already worked — just not together.
|
||||
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
|
||||
const newBandMode = slotNew && !bandWorked && !modeWorked;
|
||||
const newBand = slotNew && !bandWorked && modeWorked;
|
||||
const newMode = slotNew && bandWorked && !modeWorked;
|
||||
const newSlot = slotNew && bandWorked && modeWorked;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'flex items-center gap-4 px-3 py-2 bg-card border-b border-border flex-wrap shrink-0',
|
||||
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 border-amber-300',
|
||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-[220px]">
|
||||
{newOne ? (
|
||||
<>
|
||||
<Badge className="bg-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]">
|
||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||
<Star className="size-3 fill-current" />
|
||||
NEW ONE
|
||||
</Badge>
|
||||
<span className="text-xs text-amber-900">
|
||||
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||
<span className="text-xs text-warning-muted-foreground">
|
||||
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||
{' '}· never worked this entity
|
||||
</span>
|
||||
</>
|
||||
@@ -98,7 +156,22 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||
{callCount > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<strong className="text-foreground font-semibold">{callCount}</strong>{' '}
|
||||
with this call
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{(newBand || newMode || newBandMode || newSlot) && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{newBandMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||
{newBand && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||
{newMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||
{newSlot && <Badge className="bg-info text-info-foreground px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : busy ? (
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
||||
@@ -112,15 +185,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<table className="border-separate" style={{ borderSpacing: 2 }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[22px]" />
|
||||
{BANDS.map((b) => (
|
||||
<th className="w-[26px]" />
|
||||
{cols.map((b) => (
|
||||
<th
|
||||
key={b.tag}
|
||||
className={cn(
|
||||
'font-mono text-[10px] font-semibold px-1 text-center',
|
||||
'font-mono text-[11px] font-semibold px-1 text-center',
|
||||
b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -136,23 +210,23 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
||||
<tr key={cls}>
|
||||
<th
|
||||
className={cn(
|
||||
'font-mono text-[10px] font-semibold pr-1.5 text-right w-[22px]',
|
||||
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
|
||||
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{cls}
|
||||
</th>
|
||||
{BANDS.map((b) => {
|
||||
{cols.map((b) => {
|
||||
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||
const isCurrent = b.tag === currentBand && classCurrent;
|
||||
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
||||
return (
|
||||
<td
|
||||
key={b.tag}
|
||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||
className={cn(
|
||||
'w-[22px] h-[18px] rounded transition-colors p-0',
|
||||
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
||||
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -162,6 +236,23 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Colour legend — sits in the spare room under the matrix. */}
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap pl-[26px]">
|
||||
{LEGEND.map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-3 rounded shrink-0',
|
||||
l.swatch,
|
||||
l.ring && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BulkUpdateField } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldKind = 'status' | 'text';
|
||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||
|
||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
||||
// the backend BulkUpdateField whitelist.
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const FIELDS: FieldDef[] = [
|
||||
// QSL / upload status
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||
// My station / operator
|
||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sota_ref', label: 'bulk.fMySota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_pota_ref', label: 'bulk.fMyPota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
|
||||
// Contest
|
||||
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
|
||||
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
|
||||
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
|
||||
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
|
||||
// Propagation / satellite
|
||||
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
// Contacted station (activation refs / SIG)
|
||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||
// Misc
|
||||
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||
{ id: 'ant', label: 'bulk.fAnt', group: 'Misc', kind: 'text' },
|
||||
];
|
||||
|
||||
// label holds an i18n key.
|
||||
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||
{ v: 'Y', label: 'bulk.statusY' },
|
||||
{ v: 'N', label: 'bulk.statusN' },
|
||||
{ v: 'R', label: 'bulk.statusR' },
|
||||
{ v: 'I', label: 'bulk.statusI' },
|
||||
{ v: '_', label: 'bulk.statusBlank' },
|
||||
];
|
||||
|
||||
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
||||
// Maps the internal group key → its i18n label key.
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
'QSL / upload': 'bulk.groupQsl',
|
||||
'My station': 'bulk.groupMyStation',
|
||||
'Contacted station': 'bulk.groupContacted',
|
||||
'Contest': 'bulk.groupContest',
|
||||
'Propagation': 'bulk.groupPropagation',
|
||||
'Misc': 'bulk.groupMisc',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
ids: number[];
|
||||
onClose: () => void;
|
||||
onApplied: (n: number) => void;
|
||||
};
|
||||
|
||||
// BulkEditModal sets one QSL/upload status field to a chosen value across the
|
||||
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
||||
// so they become eligible for upload.
|
||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [field, setField] = useState('hrdlog_upload');
|
||||
const [statusValue, setStatusValue] = useState('R');
|
||||
const [textValue, setTextValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
|
||||
const isStatus = def.kind === 'status';
|
||||
const effectiveValue = isStatus
|
||||
? (statusValue === '_' ? '' : statusValue)
|
||||
: textValue.trim();
|
||||
|
||||
async function apply() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const n = await BulkUpdateField(ids as any, field, effectiveValue);
|
||||
onApplied(Number(n) || 0);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('bulk.desc', { n: ids.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-2 space-y-3">
|
||||
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('bulk.fieldLabel')}</Label>
|
||||
<Select value={field} onValueChange={setField}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{GROUPS.map((g) => (
|
||||
<div key={g}>
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
||||
{FIELDS.filter((f) => f.group === g)
|
||||
.map((f) => ({ f, txt: t(f.label) }))
|
||||
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||
.map(({ f, txt }) => (
|
||||
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
|
||||
{isStatus ? (
|
||||
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={textValue}
|
||||
placeholder={t('bulk.clearPlaceholder')}
|
||||
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('bulk.cancel')}</Button>
|
||||
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{t('bulk.applyTo', { n: ids.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Star } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -26,6 +27,7 @@ function fmtDateTime(s: any): string {
|
||||
}
|
||||
|
||||
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
const { t } = useI18n();
|
||||
const hasCall = currentCall.trim() !== '';
|
||||
const count = wb?.count ?? 0;
|
||||
const entries = wb?.entries ?? [];
|
||||
@@ -35,7 +37,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Worked before
|
||||
{t('chp.workedBefore')}
|
||||
</span>
|
||||
{hasCall && (
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
||||
@@ -52,54 +54,54 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
|
||||
{!hasCall ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign to see prior contacts.
|
||||
{t('chp.typeCallsign')}
|
||||
</div>
|
||||
) : busy && count === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
{t('chp.checking')}
|
||||
</div>
|
||||
) : count === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-6 text-primary fill-current" />
|
||||
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with this callsign.</div>
|
||||
<div className="text-lg font-bold text-primary tracking-wider">{t('chp.new')}</div>
|
||||
<div>{t('chp.noPriorQso')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
{t('chp.first')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
{t('chp.last')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Date UTC</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Band</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Mode</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="hover:bg-stone-50">
|
||||
<tr key={e.id} className="hover:bg-card">
|
||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-emerald-100 text-emerald-700">{e.mode}</span>
|
||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-success-muted text-success-muted-foreground">{e.mode}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||
{e.lotw_rcvd === 'Y' && (
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title="LoTW rcvd">L</span>
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||
)}
|
||||
{e.qsl_rcvd === 'Y' && (
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title="Bureau rcvd">B</span>
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -108,7 +110,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
</table>
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
||||
+ {count - entries.length} older QSOs
|
||||
{t('chp.olderQsos', { n: count - entries.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
||||
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
||||
|
||||
function hhmm(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
|
||||
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
|
||||
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
||||
onSend: (text: string) => void; onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const me = (myCall || '').toUpperCase();
|
||||
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
|
||||
|
||||
function send() {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
setText('');
|
||||
onSend(t);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
|
||||
<MessageSquare className="size-4 text-info" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
|
||||
<span className="flex-1" />
|
||||
{/* Online count — hover to see who's connected. */}
|
||||
<div className="relative group">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
||||
<Users className="size-3.5" />{online.length}
|
||||
</span>
|
||||
{online.length > 0 && (
|
||||
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">{t('chatp.online')}</div>
|
||||
{online.map((o) => (
|
||||
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
||||
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title={t('chatp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
||||
{msgs.length === 0 ? (
|
||||
<div className="text-muted-foreground italic text-center py-6">{t('chatp.noMessages')}</div>
|
||||
) : msgs.map((m) => {
|
||||
const mine = m.operator.toUpperCase() === me;
|
||||
return (
|
||||
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
||||
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-info-muted text-info-muted-foreground' : 'bg-muted')}>
|
||||
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
||||
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder={t('chatp.messagePh')}
|
||||
maxLength={1000}
|
||||
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
<button type="button" onClick={send} disabled={!text.trim()}
|
||||
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,25 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3 } from 'lucide-react';
|
||||
import { Columns3, FilterX } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
export type ClusterSpot = {
|
||||
source_id: number;
|
||||
@@ -57,6 +42,8 @@ export type ClusterSpot = {
|
||||
received_at: string;
|
||||
raw: string;
|
||||
repeats?: number;
|
||||
pota_ref?: string;
|
||||
pota_name?: string;
|
||||
};
|
||||
|
||||
export type SpotStatusEntry = {
|
||||
@@ -102,136 +89,174 @@ function fmtDateTimeUTC(s: any): string {
|
||||
|
||||
type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
// statusFor resolves the precomputed spot status (new / new-band / new-slot /
|
||||
// worked-call) for an ag-Grid cell's row.
|
||||
function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
return p?.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
];
|
||||
}
|
||||
|
||||
// statusBadge maps a resolved spot status to a short labelled badge for the
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{
|
||||
group: 'Spot', label: 'Time', colId: 'time',
|
||||
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Call', colId: 'call',
|
||||
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellRenderer: (p: any) => {
|
||||
if (!p.value) return '';
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const isNew = status?.status === 'new';
|
||||
const workedCall = !!status?.worked_call;
|
||||
const style: any = {
|
||||
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
|
||||
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
|
||||
};
|
||||
if (isNew) {
|
||||
style.backgroundColor = '#ffe4e6';
|
||||
style.color = '#9f1239';
|
||||
style.border = '1px solid #fda4af';
|
||||
} else if (workedCall) {
|
||||
style.color = '#0369a1';
|
||||
} else {
|
||||
style.color = '#b8410c';
|
||||
}
|
||||
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
|
||||
cellClass: 'font-mono',
|
||||
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
||||
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
||||
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
||||
// the rest of the row across every theme instead of always shouting orange.
|
||||
cellStyle: (p: any): any => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
||||
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
||||
return { fontWeight: 700 };
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Freq', colId: 'freq',
|
||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(t, statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{
|
||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{b.text}</span>
|
||||
);
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#166534' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Band', colId: 'band',
|
||||
headerName: 'Band', field: 'band' as any, width: 75,
|
||||
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'flex items-center',
|
||||
cellRenderer: (p: any) => {
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newBand = status?.status === 'new-band';
|
||||
const bg = newBand ? '#fde68a' : '#f0d9a8';
|
||||
const fg = newBand ? '#92400e' : '#7a4a14';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newBand ? '1px solid #f59e0b' : undefined,
|
||||
}}
|
||||
title={newBand ? 'NEW BAND for this entity' : undefined}
|
||||
>{p.value}</span>
|
||||
: '';
|
||||
},
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||
: undefined),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Mode', colId: 'mode',
|
||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
||||
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
|
||||
defaultVisible: true,
|
||||
cellClass: 'flex items-center',
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
cellRenderer: (p: any) => {
|
||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newSlot = status?.status === 'new-slot';
|
||||
const bg = newSlot ? '#fef08a' : '#d1fae5';
|
||||
const fg = newSlot ? '#854d0e' : '#047857';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newSlot ? '1px solid #eab308' : undefined,
|
||||
}}
|
||||
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
|
||||
>{p.value}</span>
|
||||
: <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
||||
cellStyle: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
||||
// Status badge text tells them apart.
|
||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
||||
return undefined;
|
||||
},
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
|
||||
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
||||
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
|
||||
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
||||
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
|
||||
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
|
||||
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
|
||||
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
|
||||
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Country', colId: 'country',
|
||||
headerName: 'Country', width: 140,
|
||||
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
|
||||
headerName: t('clg2.c.country'), width: 140,
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -239,8 +264,8 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Continent', colId: 'continent',
|
||||
headerName: 'Cont', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -248,50 +273,57 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
||||
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Source', colId: 'source',
|
||||
headerName: 'Source', field: 'source_name' as any, width: 100,
|
||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Comment', colId: 'comment',
|
||||
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Received at', colId: 'received_at',
|
||||
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Raw', colId: 'raw',
|
||||
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
|
||||
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['Spot', 'Geo'];
|
||||
|
||||
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
||||
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
||||
|
||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
}), [COL_CATALOG]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
@@ -303,20 +335,27 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
// change below.
|
||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||
|
||||
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
||||
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
||||
// turns blue once its status loads.
|
||||
useEffect(() => {
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
}, [spotStatus]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
|
||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||
@@ -360,8 +399,12 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title={t('clg2.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('clg2.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
@@ -390,9 +433,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cluster columns</DialogTitle>
|
||||
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Cluster table.
|
||||
{t('clg2.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
@@ -402,10 +445,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
@@ -424,8 +467,8 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { ListContests, ContestStats } from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
|
||||
// so a running contest (serial counter included) survives a restart.
|
||||
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 { t } = useI18n();
|
||||
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-warning" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
|
||||
{session.active && <span className="ml-auto rounded bg-warning px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-warning-foreground">{t('ctp.live')}</span>}
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm flex-1 min-w-[220px]">
|
||||
<option value="">{t('ctp.chooseContest')}</option>
|
||||
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||
</select>
|
||||
{session.code && <code className="text-xs text-muted-foreground">{session.code}</code>}
|
||||
</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' ? t('ctp.serial') : t('ctp.fixedExchange')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{session.exchange === 'serial' ? (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('ctp.nextSerial')}
|
||||
<input type="number" min={1} value={session.nextSerial}
|
||||
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
|
||||
className="w-20 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||
</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={t('ctp.yourExchangePh')} 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">
|
||||
{t('ctp.startUtc')}
|
||||
<input type="datetime-local" value={isoToUtcInput(session.startISO)}
|
||||
onChange={(e) => onChange({ startISO: utcInputToISO(e.target.value) })}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('ctp.endUtc')}
|
||||
<input type="datetime-local" value={isoToUtcInput(session.endISO)}
|
||||
onChange={(e) => onChange({ endISO: utcInputToISO(e.target.value) })}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">{t('ctp.windowHint')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{session.active ? (
|
||||
<button type="button" onClick={() => onChange({ active: false })}
|
||||
className="rounded-md border border-danger/60 bg-danger/10 px-4 py-1.5 text-sm font-bold text-danger hover:bg-danger/20">
|
||||
{t('ctp.stopContest')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" disabled={!session.code}
|
||||
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
|
||||
className="rounded-md bg-warning px-4 py-1.5 text-sm font-bold text-warning-foreground hover:bg-warning disabled:opacity-40">
|
||||
{t('ctp.startContest')}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.active ? t('ctp.activeHint') : t('ctp.inactiveHint')}
|
||||
</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">{t('ctp.scoreboard')}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t('ctp.estimate')}</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Stat label={t('ctp.qsos')} value={stats?.qsos ?? 0} />
|
||||
<Stat label={stats?.mult_label || t('ctp.mult')} value={stats?.mult ?? 0} accent="#f59e0b" />
|
||||
<Stat label={t('ctp.scoreEst')} value={(stats?.score ?? 0).toLocaleString()} accent="#16a34a" />
|
||||
<Stat label={t('ctp.last60')} value={stats?.last_hour ?? 0} />
|
||||
</div>
|
||||
{stats && stats.by_band && stats.by_band.length > 0 && (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left px-3 py-1.5 font-bold">{t('ctp.band')}</th>
|
||||
<th className="text-right px-3 py-1.5 font-bold">{t('ctp.qsos')}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{stats.by_band.map((r) => (
|
||||
<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';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronUp, Construction } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
|
||||
export interface DetailsState {
|
||||
state: string;
|
||||
@@ -33,9 +36,16 @@ export interface DetailsState {
|
||||
sat_name: string;
|
||||
sat_mode: string;
|
||||
contest_id: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
// Contest exchanges as free text — most are serials (001) but some are
|
||||
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
|
||||
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
|
||||
srx_string?: string;
|
||||
stx_string?: string;
|
||||
email: string;
|
||||
// Award references for the contacted station (set via the Awards tab picker).
|
||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||
award_refs?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -45,11 +55,57 @@ interface Props {
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||
wb?: any;
|
||||
wbBusy?: boolean;
|
||||
band: string;
|
||||
mode: string;
|
||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||
imageUrl?: string;
|
||||
onOpenImage?: () => void;
|
||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||
tab?: TabName;
|
||||
onTab?: (t: TabName) => void;
|
||||
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||
keyerActive?: boolean;
|
||||
}
|
||||
|
||||
type TabName = 'info' | 'awards' | 'my' | 'extended';
|
||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
|
||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
||||
const PROP_MODES: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'AS', label: 'detp.propAS' },
|
||||
{ value: 'AUR', label: 'detp.propAUR' },
|
||||
{ value: 'AUE', label: 'detp.propAUE' },
|
||||
{ value: 'BS', label: 'detp.propBS' },
|
||||
{ value: 'ECH', label: 'EchoLink' },
|
||||
{ value: 'EME', label: 'detp.propEME' },
|
||||
{ value: 'ES', label: 'detp.propES' },
|
||||
{ value: 'FAI', label: 'detp.propFAI' },
|
||||
{ value: 'F2', label: 'detp.propF2' },
|
||||
{ value: 'GWAVE', label: 'detp.propGWAVE' },
|
||||
{ value: 'INTERNET', label: 'detp.propINTERNET' },
|
||||
{ value: 'ION', label: 'detp.propION' },
|
||||
{ value: 'IRL', label: 'IRLP' },
|
||||
{ value: 'LOS', label: 'detp.propLOS' },
|
||||
{ value: 'MS', label: 'detp.propMS' },
|
||||
{ value: 'RPT', label: 'detp.propRPT' },
|
||||
{ value: 'RS', label: 'detp.propRS' },
|
||||
{ value: 'SAT', label: 'detp.propSAT' },
|
||||
{ value: 'TEP', label: 'detp.propTEP' },
|
||||
{ value: 'TR', label: 'detp.propTR' },
|
||||
];
|
||||
|
||||
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
||||
const ANT_PATHS: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'S', label: 'detp.pathShort' },
|
||||
{ value: 'L', label: 'detp.pathLong' },
|
||||
{ value: 'G', label: 'detp.pathGrayline' },
|
||||
{ value: 'O', label: 'detp.pathOther' },
|
||||
];
|
||||
|
||||
function numOrUndef(v: string): number | undefined {
|
||||
if (v === '') return undefined;
|
||||
@@ -58,28 +114,71 @@ function numOrUndef(v: string): number | undefined {
|
||||
}
|
||||
|
||||
// Compact field helper to keep the JSX dense.
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
||||
<Label className="mb-1">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) {
|
||||
const [open, setOpen] = useState<TabName | null>(null);
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Live award detection: run the SAME engine used at log time over the current
|
||||
// contact (callsign + looked-up address/state/zones) so award references the
|
||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||
useEffect(() => {
|
||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
try {
|
||||
const q: any = {
|
||||
callsign, band, mode,
|
||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||
qso_date: new Date().toISOString(),
|
||||
};
|
||||
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
|
||||
setDetected(all as any);
|
||||
const cur = details.award_refs ?? '';
|
||||
const have = new Set(cur.split(';').filter(Boolean));
|
||||
let next = cur;
|
||||
for (const r of all) {
|
||||
if (!r.pickable) continue;
|
||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||
}
|
||||
if (next !== cur) onChange({ award_refs: next });
|
||||
} catch { /* leave detection empty on failure */ }
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(
|
||||
() => pathBetween(operatorGrid, remoteGrid),
|
||||
[operatorGrid, remoteGrid],
|
||||
);
|
||||
const path = useMemo(() => {
|
||||
const byGrid = pathBetween(operatorGrid, remoteGrid);
|
||||
if (byGrid) return byGrid;
|
||||
// Fall back to lat/lon when the DX has coordinates but no grid (e.g. a
|
||||
// cty.dat-only entity like Svalbard: no QRZ grid, but cty.dat coordinates).
|
||||
const myLL = gridToLatLon(operatorGrid);
|
||||
if (myLL && details.lat != null && details.lon != null) {
|
||||
return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
|
||||
}
|
||||
return null;
|
||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); }
|
||||
function toggle(t: TabName) { onTab ? onTab(t) : setInternalOpen(t); }
|
||||
const fk = keyerActive ? 'Ctrl+F' : 'F';
|
||||
|
||||
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||
function setSatellite(on: boolean) {
|
||||
@@ -94,15 +193,16 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
}
|
||||
|
||||
const tabs: { key: TabName; label: string }[] = [
|
||||
{ key: 'info', label: 'Info (F2)' },
|
||||
{ key: 'awards', label: 'Awards (F3)' },
|
||||
{ key: 'my', label: 'My (F4)' },
|
||||
{ key: 'extended', label: 'Extended (F5)' },
|
||||
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
|
||||
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
|
||||
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
|
||||
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
|
||||
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-card shrink-0">
|
||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border">
|
||||
<section className="bg-card shadow-sm border border-border rounded-lg flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
@@ -117,32 +217,47 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{open && (
|
||||
<button
|
||||
onClick={() => setOpen(null)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
|
||||
title="Close"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="State / pref">
|
||||
<Field label={t('detp.statePref')}>
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="County">
|
||||
<Field label={t('detp.county')}>
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Prefix">
|
||||
<Field label={t('detp.prefix')}>
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
{/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the
|
||||
main entry strip — visible without opening F2. F2 keeps the
|
||||
less-needed long-path bearing and both distances. */}
|
||||
<Field label="Azimuth LP">
|
||||
<Field label={t('detp.cqZone')}>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</Field>
|
||||
<Field label={t('detp.ituZone')}>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</Field>
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={details.dxcc ?? ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -151,7 +266,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance SP">
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -160,7 +275,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance LP">
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -169,65 +284,85 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Address" span={3}>
|
||||
<Field label={t('detp.address')} span={3}>
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL message" span={3}>
|
||||
<Field label={t('detp.qslMessage')} span={3}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL via" span={2}>
|
||||
<Field label={t('detp.qslVia')} span={2}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'awards' && (
|
||||
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
|
||||
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
|
||||
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
|
||||
<AwardRefSelector
|
||||
dxcc={details.dxcc}
|
||||
value={details.award_refs ?? ''}
|
||||
onChange={(v) => onChange({ award_refs: v })}
|
||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||
heightClass="flex-1 min-h-0"
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||
{detected.map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'my' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Ant. azimuth (°)">
|
||||
<Field label={t('detp.azimuth')}>
|
||||
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Ant. elevation (°)">
|
||||
<Field label={t('detp.elevation')}>
|
||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Ant. path">
|
||||
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
|
||||
<Field label={t('detp.txPower')}>
|
||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Propagation">
|
||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||
<div className="col-span-3 flex items-end pb-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||
{t('detp.satelliteMode')}
|
||||
</label>
|
||||
</div>
|
||||
<Field label={t('detp.antPath')} span={2}>
|
||||
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="TX power (W)">
|
||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||
<Field label={t('detp.propagation')} span={4}>
|
||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="flex items-end pb-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||
Satellite mode
|
||||
</label>
|
||||
</div>
|
||||
<Field label="Rig" span={3}>
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Antenna" span={3}>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label="Satellite name" span={3}>
|
||||
<Field label={t('detp.satName')} span={3}>
|
||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Satellite mode" span={3}>
|
||||
<Field label={t('detp.satelliteMode')} span={3}>
|
||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
@@ -237,20 +372,21 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
|
||||
{open === 'extended' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Contest ID" span={2}>
|
||||
<Field label={t('detp.contestId')} span={2}>
|
||||
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="SRX">
|
||||
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
|
||||
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="STX">
|
||||
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
|
||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Contacted email" span={3}>
|
||||
<Field label={t('detp.contactedEmail')} span={3}>
|
||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, Loader2, Trash2, X } from 'lucide-react';
|
||||
import { FindDuplicates, DeleteQSOs } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type QSO = {
|
||||
id: number; callsign: string; qso_date: string; band?: string; mode?: string;
|
||||
freq_hz?: number; rst_sent?: string; rst_rcvd?: string; name?: string;
|
||||
};
|
||||
type Group = { key: string; qsos: QSO[] };
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
};
|
||||
const fmtFreq = (hz?: number) => (hz && hz > 0 ? (hz / 1e6).toFixed(3) : '—');
|
||||
|
||||
// DuplicatesModal — scans the log for duplicate QSOs (same call+band+mode on the
|
||||
// same day; optionally same minute), groups them, and lets the operator pick
|
||||
// which copies to delete. The oldest in each group is left unchecked by default.
|
||||
export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; onDeleted: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [windowMin, setWindowMin] = useState(5); // duplicates land a few minutes apart
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = (w: number) => {
|
||||
setLoading(true);
|
||||
FindDuplicates(w)
|
||||
.then((g) => {
|
||||
const gs = (g ?? []) as Group[];
|
||||
setGroups(gs);
|
||||
// Default: select every QSO except the first (oldest) of each group.
|
||||
const next = new Set<number>();
|
||||
gs.forEach((grp) => grp.qsos.slice(1).forEach((q) => next.add(q.id)));
|
||||
setSel(next);
|
||||
})
|
||||
.catch(() => { setGroups([]); setSel(new Set()); })
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(windowMin); /* eslint-disable-next-line */ }, [windowMin]);
|
||||
|
||||
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const selectExtras = () => { const n = new Set<number>(); groups.forEach((g) => g.qsos.slice(1).forEach((q) => n.add(q.id))); setSel(n); };
|
||||
const deselectAll = () => setSel(new Set());
|
||||
|
||||
const totalQsos = groups.reduce((a, g) => a + g.qsos.length, 0);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (sel.size === 0) return;
|
||||
if (!confirm(t('dup.deleteConfirm', { n: sel.size }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await DeleteQSOs(Array.from(sel) as any);
|
||||
onDeleted();
|
||||
load(windowMin); // rescan so the list reflects what's left
|
||||
} catch { /* ignore */ } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
|
||||
<div className="w-[820px] max-w-full max-h-[88vh] flex flex-col rounded-2xl border border-border bg-card shadow-2xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
||||
<Copy className="size-4 text-primary" />
|
||||
<span className="font-bold">{t('dup.title')}</span>
|
||||
<button type="button" onClick={onClose} className="ml-auto rounded-md p-1 hover:bg-muted"><X className="size-4" /></button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-5 py-3 border-b border-border/60 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('dup.hint')}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('dup.window')}
|
||||
<select value={windowMin} onChange={(e) => setWindowMin(parseInt(e.target.value, 10))}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||
{[2, 3, 5, 10, 15, 30].map((m) => <option key={m} value={m}>{m} {t('dup.minutes')}</option>)}
|
||||
<option value={0}>{t('dup.sameDay')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button type="button" onClick={selectExtras} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.selectExtras')}</button>
|
||||
<button type="button" onClick={deselectAll} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.deselectAll')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('dup.summary', { groups: groups.length, qsos: totalQsos, sel: sel.size })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-3 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> {t('dup.scanning')}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">{t('dup.none')}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.key} className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs font-bold">
|
||||
<span className="font-mono">{g.qsos[0].callsign}</span>
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{g.qsos[0].band} · {g.qsos[0].mode} · {g.qsos.length}×
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr className="border-b border-border/60">
|
||||
<th className="w-8"></th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colDate')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colFreq')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colRst')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colName')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{g.qsos.map((q, i) => {
|
||||
const checked = sel.has(q.id);
|
||||
return (
|
||||
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-danger/10')}>
|
||||
<td className="text-center py-1">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono whitespace-nowrap">
|
||||
{fmtDate(q.qso_date)}
|
||||
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-success">{t('dup.keep')}</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
|
||||
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
|
||||
<td className="px-2 py-1 truncate max-w-[160px]">{q.name || ''}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
|
||||
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
|
||||
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-bold text-destructive-foreground hover:bg-destructive disabled:opacity-40">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||
{t('dup.deleteSel', { n: sel.size })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||
|
||||
type Props = {
|
||||
messages: DVKMsg[];
|
||||
status: DVKStat;
|
||||
onPlay: (slot: number) => void;
|
||||
onStop: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const anyAudio = messages.some((m) => m.has_audio);
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||
<Mic className="size-3.5 text-primary" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
||||
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||
<Square className="size-3" /> {t('dvkp.stop')}
|
||||
</Button>
|
||||
<button className="text-muted-foreground hover:text-foreground" title={t('dvkp.disable')} onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-2">
|
||||
{!anyAudio ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
|
||||
<Radio className="size-5 opacity-50" />
|
||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{messages.map((m) => (
|
||||
<button
|
||||
key={m.slot}
|
||||
type="button"
|
||||
disabled={!m.has_audio}
|
||||
onClick={() => onPlay(m.slot)}
|
||||
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||
m.has_audio
|
||||
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||||
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
||||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span>
|
||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
|
||||
// adds field/operator/value conditions, joins them with AND or OR, and can
|
||||
// save/recall named presets. Closing the dialog applies the filter.
|
||||
|
||||
export type FilterOp =
|
||||
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
|
||||
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty';
|
||||
|
||||
export interface FilterCondition { field: string; op: FilterOp; value: string }
|
||||
export interface QueryFilter {
|
||||
quick_callsign?: string;
|
||||
conditions: FilterCondition[];
|
||||
match: 'AND' | 'OR';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||
{ value: 'mode', label: 'fltb.fMode', type: 'text' },
|
||||
{ value: 'submode', label: 'fltb.fSubmode', type: 'text' },
|
||||
{ value: 'freq_hz', label: 'fltb.fFreq', type: 'number' },
|
||||
{ value: 'freq_rx_hz', label: 'fltb.fRxFreq', type: 'number' },
|
||||
{ value: 'rst_sent', label: 'fltb.fRstSent', type: 'text' },
|
||||
{ value: 'rst_rcvd', label: 'fltb.fRstRcvd', type: 'text' },
|
||||
{ value: 'name', label: 'fltb.fName', type: 'text' },
|
||||
{ value: 'qth', label: 'fltb.fQth', type: 'text' },
|
||||
{ value: 'address', label: 'fltb.fAddress', type: 'text' },
|
||||
{ value: 'email', label: 'fltb.fEmail', type: 'text' },
|
||||
{ value: 'grid', label: 'fltb.fGrid', type: 'text' },
|
||||
{ value: 'country', label: 'fltb.fCountry', type: 'text' },
|
||||
{ value: 'state', label: 'fltb.fState', type: 'text' },
|
||||
{ value: 'cnty', label: 'fltb.fCounty', type: 'text' },
|
||||
{ value: 'dxcc', label: 'fltb.fDxcc', type: 'number' },
|
||||
{ value: 'cont', label: 'fltb.fContinent', type: 'text' },
|
||||
{ value: 'cqz', label: 'fltb.fCqz', type: 'number' },
|
||||
{ value: 'ituz', label: 'fltb.fItuz', type: 'number' },
|
||||
{ value: 'iota', label: 'fltb.fIota', type: 'text' },
|
||||
{ value: 'sota_ref', label: 'fltb.fSota', type: 'text' },
|
||||
{ value: 'pota_ref', label: 'fltb.fPota', type: 'text' },
|
||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
{ value: 'prop_mode', label: 'fltb.fPropMode', type: 'text' },
|
||||
{ value: 'sat_name', label: 'fltb.fSatellite', type: 'text' },
|
||||
{ value: 'station_callsign', label: 'fltb.fMyCallsign', type: 'text' },
|
||||
{ value: 'operator', label: 'fltb.fOperator', type: 'text' },
|
||||
{ value: 'owner_callsign', label: 'fltb.fOwnerCallsign', type: 'text' },
|
||||
{ value: 'my_grid', label: 'fltb.fMyGrid', type: 'text' },
|
||||
{ value: 'my_country', label: 'fltb.fMyCountry', type: 'text' },
|
||||
{ value: 'my_state', label: 'fltb.fMyState', type: 'text' },
|
||||
{ value: 'my_cnty', label: 'fltb.fMyCounty', type: 'text' },
|
||||
{ value: 'my_iota', label: 'fltb.fMyIota', type: 'text' },
|
||||
{ value: 'my_sota_ref', label: 'fltb.fMySota', type: 'text' },
|
||||
{ value: 'my_pota_ref', label: 'fltb.fMyPota', type: 'text' },
|
||||
{ value: 'my_wwff_ref', label: 'fltb.fMyWwff', type: 'text' },
|
||||
{ value: 'my_street', label: 'fltb.fMyStreet', type: 'text' },
|
||||
{ value: 'my_city', label: 'fltb.fMyCity', type: 'text' },
|
||||
{ value: 'my_postal_code', label: 'fltb.fMyPostal', type: 'text' },
|
||||
{ value: 'my_rig', label: 'fltb.fMyRig', type: 'text' },
|
||||
{ value: 'my_antenna', label: 'fltb.fMyAntenna', type: 'text' },
|
||||
{ value: 'tx_pwr', label: 'fltb.fTxPower', type: 'number' },
|
||||
{ value: 'comment', label: 'fltb.fComment', type: 'text' },
|
||||
{ value: 'notes', label: 'fltb.fNotes', type: 'text' },
|
||||
];
|
||||
|
||||
const OPS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'eq', label: 'fltb.opEq' },
|
||||
{ value: 'ne', label: 'fltb.opNe' },
|
||||
{ value: 'contains', label: 'fltb.opContains' },
|
||||
{ value: 'startswith', label: 'fltb.opStartsWith' },
|
||||
{ value: 'endswith', label: 'fltb.opEndsWith' },
|
||||
{ value: 'gt', label: 'fltb.opGt' },
|
||||
{ value: 'lt', label: 'fltb.opLt' },
|
||||
{ value: 'ge', label: 'fltb.opGe' },
|
||||
{ value: 'le', label: 'fltb.opLe' },
|
||||
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
||||
];
|
||||
|
||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
||||
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
|
||||
|
||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||
return OPS.filter((o) => allow.includes(o.value));
|
||||
}
|
||||
|
||||
const PRESETS_KEY = 'hamlog.filterPresets';
|
||||
function loadPresets(): Record<string, QueryFilter> {
|
||||
try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}'); } catch { return {}; }
|
||||
}
|
||||
function savePresets(p: Record<string, QueryFilter>) {
|
||||
// Write-through to the portable DB so presets travel with the data/ folder.
|
||||
writeUiPref(PRESETS_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial: QueryFilter;
|
||||
onApply: (f: QueryFilter) => void; // applies and closes
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
|
||||
// grouped by category for maintenance; the operator wants it A→Z to find things).
|
||||
const sortedFields = useMemo(
|
||||
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
|
||||
[t],
|
||||
);
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
// Seed from the active filter each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setConditions(initial.conditions?.length ? initial.conditions.map((c) => ({ ...c })) : []);
|
||||
setMatch(initial.match === 'OR' ? 'OR' : 'AND');
|
||||
setPresets(loadPresets());
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setCond = (i: number, patch: Partial<FilterCondition>) =>
|
||||
setConditions((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c)));
|
||||
const addCond = () => setConditions((cs) => [...cs, { field: 'callsign', op: 'contains', value: '' }]);
|
||||
const removeCond = (i: number) => setConditions((cs) => cs.filter((_, j) => j !== i));
|
||||
|
||||
function buildFilter(): QueryFilter {
|
||||
const clean = conditions.filter((c) => c.field && c.op);
|
||||
return { ...initial, conditions: clean, match };
|
||||
}
|
||||
|
||||
function apply() { onApply(buildFilter()); }
|
||||
|
||||
function saveCurrentPreset() {
|
||||
const name = presetName.trim();
|
||||
if (!name) return;
|
||||
const next = { ...presets, [name]: buildFilter() };
|
||||
savePresets(next);
|
||||
setPresets(next);
|
||||
setPresetName('');
|
||||
}
|
||||
function loadPreset(name: string) {
|
||||
const f = presets[name];
|
||||
if (!f) return;
|
||||
setConditions(f.conditions?.map((c) => ({ ...c })) ?? []);
|
||||
setMatch(f.match === 'OR' ? 'OR' : 'AND');
|
||||
}
|
||||
function deletePreset(name: string) {
|
||||
const next = { ...presets };
|
||||
delete next[name];
|
||||
savePresets(next);
|
||||
setPresets(next);
|
||||
}
|
||||
|
||||
const presetNames = Object.keys(presets).sort();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader className="px-6 py-4">
|
||||
<DialogTitle>{t('fltb.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-4 space-y-5">
|
||||
{/* Match mode + presets */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t('fltb.match')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{(['AND', 'OR'] as const).map((m) => (
|
||||
<button key={m} type="button" onClick={() => setMatch(m)}
|
||||
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
|
||||
{m === 'AND' ? t('fltb.all') : t('fltb.any')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{presetNames.length > 0 && (
|
||||
<Select onValueChange={loadPreset}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder={t('fltb.loadPreset')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{presetNames.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{n}
|
||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conditions */}
|
||||
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
|
||||
{conditions.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground py-4 text-center">{t('fltb.noConditions')}</div>
|
||||
)}
|
||||
{conditions.map((c, i) => {
|
||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? t('fltb.where') : match}</span>
|
||||
<Select value={c.field} onValueChange={(v) => {
|
||||
// Reset op if the new field type doesn't allow the current one.
|
||||
const allowed = opsFor(v).map((o) => o.value);
|
||||
setCond(i, { field: v, op: allowed.includes(c.op) ? c.op : allowed[0] });
|
||||
}}>
|
||||
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{t(o.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('fltb.addCondition')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Save preset */}
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<Input className="h-8 w-56 text-xs" placeholder={t('fltb.presetNamePh')} value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
|
||||
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
||||
<Button variant="ghost" onClick={() => { setConditions([]); }}>{t('fltb.clear')}</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('fltb.cancel')}</Button>
|
||||
<Button onClick={apply}>{t('fltb.applyClose')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Radio } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
|
||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
|
||||
// FirstRunModal collects the mandatory station identity on the very first launch
|
||||
// (no callsign configured yet). It writes straight into the active profile, so
|
||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const [p, setP] = useState<Profile | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
|
||||
const [refsMsg, setRefsMsg] = useState('');
|
||||
|
||||
async function downloadRefs() {
|
||||
setRefsState('loading');
|
||||
try {
|
||||
const summary = await DownloadAllReferenceLists();
|
||||
setRefsMsg(summary);
|
||||
setRefsState('done');
|
||||
} catch (e: any) {
|
||||
setRefsMsg(String(e?.message ?? e));
|
||||
setRefsState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
|
||||
}, []);
|
||||
|
||||
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
|
||||
|
||||
const callsign = (p?.callsign ?? '').trim().toUpperCase();
|
||||
const grid = (p?.my_grid ?? '').trim().toUpperCase();
|
||||
const operator = (p?.operator ?? '').trim().toUpperCase();
|
||||
const canSave = callsign.length >= 3 && grid.length >= 4;
|
||||
|
||||
async function save() {
|
||||
if (!p || !canSave) return;
|
||||
setSaving(true);
|
||||
setErr('');
|
||||
try {
|
||||
await SaveProfile({
|
||||
...p,
|
||||
callsign,
|
||||
my_grid: grid,
|
||||
operator: operator || callsign,
|
||||
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
|
||||
op_name: (p.op_name ?? '').trim(),
|
||||
} as any);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||
{/* Language chooser — lives here (and not only in the localStorage-backed
|
||||
first-launch flag gate) so a fresh setup always offers EN/FR, like the
|
||||
station identity below. */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<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 transition-colors',
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Radio className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('frm.intro')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||
<Label className="text-sm">{t('frm.callsign')} <span className="text-destructive">*</span></Label>
|
||||
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">{t('frm.locator')} <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">{t('frm.operator')}</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder={t('frm.operatorPh')} value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">{t('frm.owner')}</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder={t('frm.ownerPh')} value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">{t('frm.name')}</Label>
|
||||
<Input className="h-9" placeholder={t('frm.namePh')} value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium">{t('frm.awardRefs')}</div>
|
||||
<div className="text-muted-foreground">{t('frm.awardRefsHint')}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
|
||||
</Button>
|
||||
</div>
|
||||
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-success' : 'text-destructive')}>{refsMsg}</div>}
|
||||
</div>
|
||||
|
||||
{err && <div className="mt-3 text-xs text-destructive">{err}</div>}
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
|
||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? t('frm.saving') : t('frm.startLogging')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
|
||||
import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
|
||||
type FlexState = {
|
||||
available: boolean; model?: string;
|
||||
rf_power: number; tune_power: number; tune: boolean; transmitting: boolean;
|
||||
vox_enable: boolean; vox_level: number; vox_delay: number;
|
||||
proc_enable: boolean; proc_level: number;
|
||||
mon: boolean; mon_level: number; mic_level: number;
|
||||
atu_status?: string; atu_memories: boolean;
|
||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||
wnb: boolean; wnb_level: number;
|
||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||
mode?: string;
|
||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||
meters?: Meter[];
|
||||
slices?: FlexSlice[];
|
||||
};
|
||||
|
||||
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
||||
type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
|
||||
|
||||
const ZERO: FlexState = {
|
||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
||||
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
|
||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
|
||||
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||
amp_available: false, amp_operate: false,
|
||||
};
|
||||
|
||||
function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max = 100 }: {
|
||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; max?: number;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(max, value));
|
||||
const pct = max > 0 ? (v / max) * 100 : 0;
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||||
// is ignored), so attach a non-passive native listener; read live values via
|
||||
// refs to avoid stale closures.
|
||||
const valRef = useRef(value); valRef.current = value;
|
||||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||
const disRef = useRef(disabled); disRef.current = disabled;
|
||||
const stepRef = useRef(step); stepRef.current = step;
|
||||
const maxRef = useRef(max); maxRef.current = max;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
e.preventDefault();
|
||||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
|
||||
if (nv !== valRef.current) cbRef.current(nv);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="range" min={0} max={max} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
|
||||
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Segmented — radio-style multi-choice (AGC, Processor preset).
|
||||
function Segmented({ value, options, onChange, disabled }: {
|
||||
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||
{options.map((o) => (
|
||||
<button key={o.v} type="button" disabled={disabled} onClick={() => onChange(o.v)}
|
||||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
||||
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||
}) {
|
||||
const onCls = {
|
||||
emerald: 'bg-success border-success text-success-foreground',
|
||||
violet: 'bg-info border-info text-info-foreground',
|
||||
cyan: 'bg-info border-info text-info-foreground',
|
||||
amber: 'bg-warning border-warning text-warning-foreground',
|
||||
}[accent];
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled}
|
||||
className={cn(
|
||||
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
|
||||
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent={accent} />
|
||||
<Slider value={value} disabled={disabled || !on} accent={sliderAccent} onChange={onLevel} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
|
||||
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
|
||||
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
|
||||
// driven identically.
|
||||
//
|
||||
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
|
||||
// flipping RIT back on returns you exactly where you were, like the radio's own
|
||||
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
|
||||
const OFFSET_MAX = 99999; // SmartSDR's limit
|
||||
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
|
||||
disabled?: boolean; title?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const step = useRef((_d: number) => {});
|
||||
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
|
||||
|
||||
// React's onWheel is passive, so preventDefault() there is ignored and the panel
|
||||
// scrolls under the cursor instead of the value changing. Bind it natively.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||
step.current(e.deltaY < 0 ? mag : -mag);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
|
||||
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
|
||||
if (!up && !dn) return;
|
||||
e.preventDefault();
|
||||
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||
step.current(up ? mag : -mag);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={title}>
|
||||
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||
<div ref={ref} tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||
'focus:outline-none focus:ring-2 focus:ring-info/50',
|
||||
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
|
||||
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">−</button>
|
||||
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
|
||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz || 0)} Hz
|
||||
</span>
|
||||
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
|
||||
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
|
||||
</div>
|
||||
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
|
||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||
const METER_SEGMENTS = 26;
|
||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||
}) {
|
||||
const span = hi - lo;
|
||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||
return (
|
||||
<div onClick={onClick} title={title}
|
||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||
{display !== undefined ? display : (
|
||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||
const on = i < lit;
|
||||
const frac = i / METER_SEGMENTS;
|
||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||
return (
|
||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||
style={on
|
||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
const hold = useRef<Record<string, number>>({});
|
||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||
// read steadily instead of jumping every poll.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = (await GetFlexState()) as any as FlexState;
|
||||
if (!alive) return;
|
||||
setSt((prev) => {
|
||||
const now = Date.now();
|
||||
const merged: any = { ...s };
|
||||
for (const k in hold.current) {
|
||||
if (hold.current[k] > now) merged[k] = (prev as any)[k];
|
||||
}
|
||||
return merged as FlexState;
|
||||
});
|
||||
} catch { /* not connected */ }
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 400);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||
hold.current[key] = Date.now() + 900;
|
||||
setSt((p) => ({ ...p, [key]: val }));
|
||||
send().catch(() => {});
|
||||
};
|
||||
|
||||
const off = !st.available;
|
||||
const rxOff = off || !st.rx_avail;
|
||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||
const CW_BW = [100, 200, 300, 400, 500];
|
||||
const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000];
|
||||
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||
// Highlight the preset CLOSEST to the radio's actual filter width. The rig
|
||||
// rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as
|
||||
// 100–2790), so an exact/±50 match would leave nothing lit — "doesn't pick up
|
||||
// the current filter". Snapping to the nearest preset always reflects the rig.
|
||||
const ssbActiveBW = curBW > 0
|
||||
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
||||
: -1;
|
||||
|
||||
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
|
||||
// user wanted these in the header to save vertical space in the Meters card.
|
||||
const hdrMeters = st.meters || [];
|
||||
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
const voltM = hdrFind('13.8') || hdrFind('VOLT');
|
||||
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||
{/* Header strip */}
|
||||
<div className="flex items-center gap-3 rounded-xl px-4 py-3 text-white shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg,#1e293b,#0f172a)' }}>
|
||||
<Radio className="size-6 text-sky-400" />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
|
||||
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
|
||||
{!off && (voltM || patempM) && (
|
||||
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
|
||||
{voltM && (
|
||||
<span className="flex flex-col items-end leading-none">
|
||||
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
|
||||
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
|
||||
</span>
|
||||
)}
|
||||
{patempM && (
|
||||
<span className="flex flex-col items-end leading-none">
|
||||
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
|
||||
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||
!st.available ? 'bg-muted text-muted-foreground'
|
||||
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
||||
<span className="size-2 rounded-full bg-current" />
|
||||
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slices A/B/C/D — every in-use receiver. Click one to make it the
|
||||
ACTIVE slice: the main frequency, mode, DSP and spot-clicks all follow
|
||||
it. The active slice is highlighted; the TX slice is flagged. */}
|
||||
{!!st.slices && st.slices.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{st.slices.map((sl) => (
|
||||
<button key={sl.index} type="button" disabled={off}
|
||||
onClick={() => FlexSetActiveSlice(sl.index).catch(() => {})}
|
||||
title={t('flxp.sliceHint')}
|
||||
className={cn('flex items-center gap-2 rounded-lg border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||
sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}>
|
||||
<span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold',
|
||||
sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="font-mono font-bold tabular-nums text-sm leading-none">{(sl.freq_hz / 1e6).toFixed(3)}</span>
|
||||
<span className="text-[11px] text-muted-foreground uppercase leading-none">{sl.mode}</span>
|
||||
{sl.band ? <span className="text-[10px] text-muted-foreground leading-none">{sl.band}</span> : null}
|
||||
</span>
|
||||
{/* TX badge — click a non-TX slice's badge to move TX onto it
|
||||
(e.g. transmit on the active slice). stopPropagation so it
|
||||
doesn't also fire the outer "make active" click. */}
|
||||
<span role="button" tabIndex={-1}
|
||||
onClick={(e) => { e.stopPropagation(); if (!sl.tx && !off) FlexSetTXSlice(sl.index).catch(() => {}); }}
|
||||
title={sl.tx ? t('flxp.txSlice') : t('flxp.setTxSlice')}
|
||||
className={cn('rounded text-[10px] font-bold px-1',
|
||||
sl.tx ? 'bg-danger text-danger-foreground' : 'border border-danger/50 text-danger cursor-pointer hover:bg-danger/10')}>
|
||||
TX
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
|
||||
<Card icon={Gauge} title={t('flxp.meters')}>
|
||||
{(() => {
|
||||
const meters = st.meters || [];
|
||||
if (off || meters.length === 0) {
|
||||
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
|
||||
}
|
||||
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
// Radio meters (exclude the amplifier's, which we show separately).
|
||||
const radio = (name: string) => meters.find((m) =>
|
||||
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
// Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
|
||||
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
|
||||
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
|
||||
const sliceMeter = (name: string) => {
|
||||
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
|
||||
if (m.length === 0) return undefined;
|
||||
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
|
||||
};
|
||||
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
|
||||
const fwd = radio('FWDPWR');
|
||||
const swr = radio('SWR');
|
||||
// Mic input level + speech-compression (voltage & PA temp live in the
|
||||
// header now). All already in the meter stream.
|
||||
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
const comp = radio('COMPPEAK') || radio('COMP');
|
||||
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||||
const sUnit = (dbm: number) => {
|
||||
const sv = (dbm + 127) / 6; // S0 = -127 dBm
|
||||
if (sv >= 9) {
|
||||
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
|
||||
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
|
||||
}
|
||||
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
|
||||
};
|
||||
const cur = [
|
||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
||||
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||
); })(),
|
||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||
); })(),
|
||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
|
||||
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
||||
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||
// Speech compression (dB of gain reduction).
|
||||
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
{off && (
|
||||
<div className="text-center text-sm text-muted-foreground py-6">
|
||||
{t('flxp.waiting')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TX + RX columns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* TRANSMIT */}
|
||||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
||||
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.tunePwr')}</span>
|
||||
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
|
||||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.tune ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
TUNE
|
||||
</button>
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.transmitting ? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50' : 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||||
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.splitHint')}
|
||||
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||
SPLIT
|
||||
</button>
|
||||
{st.split && !!st.tx_freq_hz && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||
{!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{!isCW ? (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
|
||||
onClick={() => change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} />
|
||||
<Segmented value={String(st.proc_level)} options={PROC} disabled={off || !st.proc_enable}
|
||||
onChange={(v) => change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
|
||||
<span className="flex-1" />
|
||||
</div>
|
||||
<LevelRow label="VOX" on={st.vox_enable} disabled={off} value={st.vox_level} sliderAccent="#16a34a"
|
||||
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
||||
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">{t('flxp.voxDly')}</span>
|
||||
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
|
||||
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
|
||||
</div>
|
||||
<LevelRow label="MON" on={st.mon} disabled={off} value={st.mon_level} accent="cyan" sliderAccent="#0891b2"
|
||||
onToggle={() => change('mon', !st.mon, () => FlexSetMon(!st.mon))}
|
||||
onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">MIC</span>
|
||||
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||
</div>
|
||||
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
|
||||
blur/Enter so we don't spam a command per keystroke; the hold guard
|
||||
keeps the poll from clobbering the field mid-edit. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
|
||||
{(() => {
|
||||
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
|
||||
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
|
||||
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
|
||||
return (<>
|
||||
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
|
||||
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
|
||||
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||
<span className="text-muted-foreground text-xs">–</span>
|
||||
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
|
||||
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
|
||||
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||
</>);
|
||||
})()}
|
||||
</div>
|
||||
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
|
||||
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
|
||||
<select disabled={off} value={st.mic_profile || ''}
|
||||
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
|
||||
{!st.mic_profiles.includes(st.mic_profile || '') && <option value="">—</option>}
|
||||
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.speed')}</span>
|
||||
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.pitch')}</span>
|
||||
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.delay')}</span>
|
||||
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||||
</div>
|
||||
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
|
||||
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
|
||||
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* RECEIVE */}
|
||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||
<select disabled={rxOff} value={st.rx_ant ?? ''}
|
||||
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
|
||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<span className="text-[10px] text-muted-foreground">TX</span>
|
||||
<select disabled={rxOff} value={st.tx_ant ?? ''}
|
||||
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
|
||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
||||
listening (chasing a station that drifts off your transmit frequency),
|
||||
so it belongs with the other things you touch mid-QSO. Both act on the
|
||||
ACTIVE slice and follow slice focus like every control here. */}
|
||||
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
||||
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||
<button type="button" disabled={rxOff}
|
||||
title={st.mute ? t('flxp.muted') : t('flxp.mute')}
|
||||
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||||
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||||
st.mute ? 'bg-destructive text-destructive-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||||
</button>
|
||||
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
|
||||
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||||
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||||
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||||
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||||
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
|
||||
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||
{/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */}
|
||||
{!isCW && (
|
||||
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||
)}
|
||||
</div>
|
||||
{isCW && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||||
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{CW_BW.map((bw) => (
|
||||
<button key={bw} type="button" disabled={rxOff}
|
||||
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
|
||||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{bw}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCW && (
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{SSB_BW.map((bw) => (
|
||||
<button key={bw} type="button" disabled={rxOff}
|
||||
onClick={() => {
|
||||
const lsb = (st.mode || '').toUpperCase().includes('LSB');
|
||||
let lo: number, hi: number;
|
||||
if (lsb) { const near = (st.filter_hi && st.filter_hi < 0) ? st.filter_hi : -100; hi = near; lo = near - bw; }
|
||||
else { const near = (st.filter_lo && st.filter_lo > 0) ? st.filter_lo : 100; lo = near; hi = near + bw; }
|
||||
setSt((p) => ({ ...p, filter_lo: lo, filter_hi: hi }));
|
||||
FlexSetFilter(lo, hi).catch(() => {});
|
||||
}}
|
||||
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||
bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{(bw / 1000).toFixed(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/70 font-mono">kHz</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||
{st.amp_available && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
{(pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||||
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
|
||||
>
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||
and shown compact so they sit with the amp controls. */}
|
||||
{(() => {
|
||||
const meters = st.meters || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (off || amp.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amp.map((m) => {
|
||||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||
}
|
||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||
// value itself is fine — only the bar's range was wrong. Give it a
|
||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||
// only override an unusable reported range, not a sane one.
|
||||
let lo = m.lo, hi = m.hi;
|
||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||
lo = 0;
|
||||
hi = m.hi >= 25 ? m.hi : 25;
|
||||
}
|
||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
||||
import {
|
||||
GetIcomState, IcomRefresh,
|
||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
|
||||
type IcomState = {
|
||||
available: boolean; model?: string; mode?: string;
|
||||
transmitting: boolean; split: boolean;
|
||||
s_meter: number; power_meter: number; swr_meter: number;
|
||||
rf_power: number; mic_gain: number;
|
||||
af_gain: number; rf_gain: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean;
|
||||
agc?: string; preamp: number; att: number; filter: number;
|
||||
rit_hz: number; rit_on: boolean; xit_on: boolean;
|
||||
antenna: number;
|
||||
pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number;
|
||||
squelch: number; comp: boolean; comp_level: number;
|
||||
monitor: boolean; mon_level: number;
|
||||
vox: boolean; vox_gain: number; anti_vox: number;
|
||||
};
|
||||
|
||||
const ZERO: IcomState = {
|
||||
available: false, transmitting: false, split: false,
|
||||
s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0,
|
||||
af_gain: 0, rf_gain: 0,
|
||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, apf: false,
|
||||
preamp: 0, att: 0, filter: 1,
|
||||
rit_hz: 0, rit_on: false, xit_on: false,
|
||||
antenna: 1,
|
||||
pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50,
|
||||
squelch: 0, comp: false, comp_level: 0,
|
||||
monitor: false, mon_level: 0,
|
||||
vox: false, vox_gain: 0, anti_vox: 0,
|
||||
};
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||||
const BANDS: { l: string; hz: number }[] = [
|
||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||
{ l: '6', hz: 50_150_000 },
|
||||
];
|
||||
|
||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||||
// SSB by frequency and the rig's data variant for digital modes.
|
||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||||
|
||||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
||||
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||||
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||||
function attOptions(model?: string): { v: string; l: string }[] {
|
||||
const m = (model ?? '').toUpperCase();
|
||||
const OFF = { v: '0', l: 'OFF' };
|
||||
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
||||
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||
}
|
||||
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||||
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||
}
|
||||
|
||||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||
function fmtVFO(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '––.–––.––';
|
||||
const mhz = Math.floor(hz / 1_000_000);
|
||||
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||
const h2 = Math.floor((hz % 1000) / 10);
|
||||
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||||
function modeMatches(btn: string, cur?: string): boolean {
|
||||
if (!cur) return false;
|
||||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
||||
return btn === cur;
|
||||
}
|
||||
|
||||
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||||
// is ignored), so attach a non-passive native listener; read live values via
|
||||
// refs to avoid stale closures.
|
||||
const valRef = useRef(value); valRef.current = value;
|
||||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||
const disRef = useRef(disabled); disRef.current = disabled;
|
||||
const stepRef = useRef(step); stepRef.current = step;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
e.preventDefault();
|
||||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||
const nv = Math.max(0, Math.min(100, valRef.current + d));
|
||||
if (nv !== valRef.current) cbRef.current(nv);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="range" min={0} max={100} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||||
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Segmented({ value, options, onChange }: {
|
||||
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||
{options.map((o) => (
|
||||
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelRow({ label, on, onToggle, value, onLevel }: {
|
||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={on} onClick={onToggle} label={label} />
|
||||
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
|
||||
// Optional onClick makes the row a button (used to send the S reading to RST tx).
|
||||
function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: string; onClick?: () => void; title?: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const body = (
|
||||
<>
|
||||
<span className="w-9 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
||||
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||||
</div>
|
||||
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
||||
</>
|
||||
);
|
||||
if (onClick) {
|
||||
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
||||
}
|
||||
return <div className="flex items-center gap-2">{body}</div>;
|
||||
}
|
||||
|
||||
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
||||
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
||||
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
||||
label: string; on: boolean; hz: number; accent: string;
|
||||
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const cb = useRef(onDelta); cb.current = onDelta;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={on} onClick={onToggle} label={label} />
|
||||
<div ref={ref} title="Wheel / ± to shift"
|
||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
||||
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
||||
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||
</span>
|
||||
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
||||
</div>
|
||||
<button type="button" onClick={onClear}
|
||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||
// the RST-tx value on click.
|
||||
function sParts(v: number): { s: number; over: number; label: string } {
|
||||
if (v >= 47) {
|
||||
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, Math.round(v / 5.2)));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
|
||||
// (near-black → blue → cyan → green → amber → red).
|
||||
const WF_STOPS: [number, [number, number, number]][] = [
|
||||
[0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
|
||||
[0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
|
||||
];
|
||||
function wfColor(v: number): [number, number, number] {
|
||||
v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
|
||||
for (let i = 1; i < WF_STOPS.length; i++) {
|
||||
if (v <= WF_STOPS[i][0]) {
|
||||
const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
|
||||
const f = (v - a) / (b - a || 1);
|
||||
return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
|
||||
}
|
||||
}
|
||||
return WF_STOPS[WF_STOPS.length - 1][1];
|
||||
}
|
||||
|
||||
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
||||
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
||||
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
||||
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
||||
function ScopePanadapter() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(false);
|
||||
const [fixed, setFixed] = useState(true);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
||||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
||||
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
||||
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
||||
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
||||
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
||||
|
||||
const toggle = () => {
|
||||
const next = !on;
|
||||
setOn(next);
|
||||
IcomSetScope(next).catch(() => {});
|
||||
};
|
||||
|
||||
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
|
||||
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
|
||||
// just writes the rig's fixed edges — simple and independent of the waveform
|
||||
// decode.
|
||||
const SCOPE_HALF = 50_000;
|
||||
const applyEdges = (center: number) => {
|
||||
if (center <= 0) return;
|
||||
centerRef.current = center;
|
||||
setFixed(true);
|
||||
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
|
||||
};
|
||||
const centerOnVfo = async () => {
|
||||
let c = vfoRef.current;
|
||||
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
|
||||
applyEdges(c);
|
||||
};
|
||||
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
|
||||
const setMode = (nextFixed: boolean) => {
|
||||
setFixed(nextFixed);
|
||||
IcomSetScopeMode(nextFixed).catch(() => {});
|
||||
};
|
||||
// Stop the stream when the panel unmounts.
|
||||
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!on) return;
|
||||
let raf = 0, lastSeq = -1, alive = true;
|
||||
const tick = async () => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
const sw = await IcomScopeData();
|
||||
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
|
||||
lastSeq = sw.seq;
|
||||
setFixed(sw.fixed);
|
||||
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
|
||||
let vfo = 0;
|
||||
try {
|
||||
const cs = await GetCATState();
|
||||
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
||||
} catch {}
|
||||
if (vfo > 0) vfoRef.current = vfo;
|
||||
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
|
||||
}
|
||||
} catch {}
|
||||
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [on]);
|
||||
|
||||
// Double-click tunes the rig to the clicked frequency.
|
||||
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
const { low, high } = spanRef.current;
|
||||
if (!cv || !(low > 0 && high > low)) return;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
|
||||
SetCATFrequency(hz).catch(() => {});
|
||||
};
|
||||
|
||||
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
|
||||
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
|
||||
// accumulate before the poll reconciles.
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el || !on) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!vfoRef.current) return;
|
||||
e.preventDefault();
|
||||
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
|
||||
vfoRef.current = next;
|
||||
SetCATFrequency(next).catch(() => {});
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [on]);
|
||||
|
||||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = cv.clientWidth, h = cv.clientHeight;
|
||||
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
||||
const peak = Math.max(...amp);
|
||||
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
||||
const scale = peakRef.current;
|
||||
const n = amp.length;
|
||||
|
||||
// Background — deep navy vertical gradient.
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
||||
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid.
|
||||
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||
|
||||
const xOf = (i: number) => (i / (n - 1)) * w;
|
||||
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
|
||||
|
||||
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
|
||||
const hold = holdRef.current;
|
||||
if (hold.length !== n) hold.length = n, hold.fill(0);
|
||||
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
|
||||
|
||||
// Filled spectrum area.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
||||
ctx.lineTo(w, h); ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
||||
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
||||
ctx.fillStyle = grad; ctx.fill();
|
||||
|
||||
// Peak-hold trace (thin, faint).
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
|
||||
// Live spectrum trace with a soft glow.
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
|
||||
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
|
||||
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
|
||||
// you can tell which way to tune to get it back on-screen).
|
||||
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
|
||||
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
|
||||
let markerX = -1;
|
||||
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
|
||||
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
||||
else if (!fixedMode) markerX = w / 2;
|
||||
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
|
||||
if (markerX >= 0) {
|
||||
const x = markerX;
|
||||
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
|
||||
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(244,63,94,0.95)';
|
||||
if (offEdge === 0) {
|
||||
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
|
||||
} else {
|
||||
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
||||
// we label low/centre/high from them. In centre mode the header frame's edge
|
||||
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
||||
// so we always label the centre with the live VFO frequency (which we fetch
|
||||
// each sweep), and only add edge labels when the reported edges genuinely
|
||||
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
||||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
||||
ctx.font = '10px ui-monospace, monospace';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
|
||||
ctx.fillStyle = 'rgba(226,232,240,0.85)';
|
||||
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
|
||||
const validEdges = lowHz > 0 && highHz > lowHz;
|
||||
if (fixedMode) {
|
||||
if (validEdges) {
|
||||
label(mhz(lowHz), 4, 'left');
|
||||
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
|
||||
label(mhz(highHz), w - 4, 'right');
|
||||
}
|
||||
} else {
|
||||
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
|
||||
label(mhz(lowHz), 4, 'left');
|
||||
label(mhz(highHz), w - 4, 'right');
|
||||
}
|
||||
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
drawWaterfall(amp, scale);
|
||||
};
|
||||
|
||||
// drawWaterfall scrolls the history down one row and paints the newest sweep
|
||||
// as a colour-mapped line at the top.
|
||||
const drawWaterfall = (amp: number[], scale: number) => {
|
||||
const cv = wfRef.current;
|
||||
if (!cv) return;
|
||||
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
|
||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
// Scroll everything down by one pixel row.
|
||||
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
||||
// Paint the new top row.
|
||||
const row = ctx.createImageData(w, 1);
|
||||
const n = amp.length;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
|
||||
const [r, g, b] = wfColor(amp[i] / scale);
|
||||
const o = x * 4;
|
||||
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(row, 0, 0);
|
||||
};
|
||||
|
||||
// Collapsible card: when the scope is off, only the header band shows (the
|
||||
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
|
||||
// and ON/OFF controls live in the header itself.
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
{on && (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
|
||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">◀</button>
|
||||
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
|
||||
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">⊙</button>
|
||||
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
|
||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted">▶</button>
|
||||
</div>
|
||||
)}
|
||||
{on && (
|
||||
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
||||
onChange={(v) => setMode(v === 'FIX')} />
|
||||
)}
|
||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||||
</div>
|
||||
</div>
|
||||
{on && (
|
||||
<div className="p-3">
|
||||
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||||
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
||||
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
|
||||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
||||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||||
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<IcomState>(ZERO);
|
||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||
const [tuning, setTuning] = useState(false);
|
||||
const txRef = useRef(false);
|
||||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||
|
||||
const load = () => {
|
||||
GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||
};
|
||||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||
const refresh = async () => {
|
||||
try { await IcomRefresh(); } catch {}
|
||||
await load();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
|
||||
return () => window.clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Optimistic local update + fire the command; the cache poll reconciles.
|
||||
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
||||
setSt((s) => ({ ...s, ...patch }));
|
||||
fn().catch(() => {});
|
||||
};
|
||||
|
||||
const toggleMox = () => {
|
||||
const next = !txRef.current;
|
||||
txRef.current = next;
|
||||
set({ transmitting: next }, () => IcomSetPTT(next));
|
||||
};
|
||||
|
||||
const tune = async () => {
|
||||
setTuning(true);
|
||||
try { await IcomTune(); } catch {}
|
||||
window.setTimeout(() => setTuning(false), 4000);
|
||||
};
|
||||
|
||||
// RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
|
||||
const setRit = (hz: number) => {
|
||||
const v = Math.max(-9999, Math.min(9999, hz));
|
||||
set({ rit_hz: v }, () => IcomSetRIT(v));
|
||||
};
|
||||
|
||||
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
|
||||
// clarifier for zero-beating a caller without touching the mouse.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||
const s = stRef.current;
|
||||
if (!s.available || !s.rit_on) return;
|
||||
e.preventDefault();
|
||||
setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!st.available) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||
{t('icmp.notConnected')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tx = st.transmitting;
|
||||
// VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the
|
||||
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||||
const split = !!cat?.split;
|
||||
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||||
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
||||
const curMode: string = cat?.mode || st.mode || '';
|
||||
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||||
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||||
const um = curMode.toUpperCase();
|
||||
const isCW = um === 'CW' || um === 'CWR';
|
||||
const isPhone = um === 'SSB' || um === 'USB' || um === 'LSB' || um === 'AM' || um === 'FM';
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
||||
tx ? 'bg-destructive text-destructive-foreground' : 'bg-success text-success-foreground')}>
|
||||
<span className={cn('size-2 rounded-full', tx ? 'bg-card animate-pulse' : 'bg-card/90')} />
|
||||
{tx ? 'TX' : 'RX'}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
|
||||
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
||||
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||||
unpowered while the rig is off, so power-ON can't reach it (OFF works
|
||||
but ON doesn't); hiding both avoids a dead button. On the network the
|
||||
rig's LAN server stays alive in standby, so both work. */}
|
||||
{isNetwork && (
|
||||
<>
|
||||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||
<Power className="size-3.5" /> ON
|
||||
</button>
|
||||
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||||
<Power className="size-3.5" /> OFF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big
|
||||
tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
|
||||
<div className="rounded-xl border border-border bg-muted/25 shadow-inner overflow-hidden">
|
||||
<div className="grid grid-cols-2 divide-x divide-border/60">
|
||||
{/* MAIN VFO */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={cn('text-[10px] font-bold uppercase tracking-widest', tx ? 'text-destructive' : 'text-success')}>{tx ? 'Main · TX' : 'Main'}</span>
|
||||
{curMode ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-primary/15 text-primary">{curMode}</span> : null}
|
||||
</div>
|
||||
<div className="font-mono font-bold tabular-nums leading-none text-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(mainHz)}</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] font-mono text-muted-foreground">
|
||||
<span>{cat?.band || (mainHz ? '' : '—')}</span>
|
||||
{st.rit_on ? <span className="text-primary">RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)}</span> : null}
|
||||
{st.xit_on ? <span className="text-warning">ΔTX</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* SUB VFO (populated in split; dimmed otherwise) */}
|
||||
<div className={cn('px-4 py-3', !split && 'opacity-40')}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Sub</span>
|
||||
{split ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-warning/15 text-warning">SPLIT</span> : null}
|
||||
</div>
|
||||
<div className="font-mono font-bold tabular-nums leading-none text-muted-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(subHz)}</div>
|
||||
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground">{split ? 'TX' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
|
||||
{MODES.map((m) => {
|
||||
const on = modeMatches(m, curMode);
|
||||
return (
|
||||
<button key={m} type="button" onClick={() => setMode(m)}
|
||||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||
on ? 'bg-primary text-primary-foreground' : 'bg-card/40 text-muted-foreground hover:bg-muted')}>
|
||||
{m}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
||||
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
||||
{(() => { const sp = sParts(st.s_meter); return (
|
||||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
||||
); })()}
|
||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
|
||||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||||
</div>
|
||||
|
||||
{/* Spectrum panadapter (full width). */}
|
||||
<ScopePanadapter />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{BANDS.map((b) => (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
||||
{b.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Row label={t('icmp.antenna')}>
|
||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Transmit controls. */}
|
||||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||||
<Row label={t('icmp.power')}>
|
||||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
||||
</Row>
|
||||
{isPhone && (
|
||||
<Row label={t('icmp.mic')}>
|
||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||
</Row>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button type="button" onClick={toggleMox}
|
||||
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
|
||||
tx ? 'bg-destructive border-destructive text-destructive-foreground' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||
{tx ? 'TX ON' : 'MOX'}
|
||||
</button>
|
||||
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||||
<button type="button" onClick={tune} disabled={tuning}
|
||||
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||
TUNE
|
||||
</button>
|
||||
</div>
|
||||
{/* Monitor (all modes) + speech processor / VOX (voice modes only —
|
||||
they don't exist on CW or data). */}
|
||||
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
|
||||
<LevelRow label="MON" on={st.monitor} value={st.mon_level}
|
||||
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
|
||||
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} />
|
||||
{isPhone && (
|
||||
<>
|
||||
<LevelRow label="COMP" on={st.comp} value={st.comp_level}
|
||||
onToggle={() => set({ comp: !st.comp }, () => IcomSetComp(!st.comp))}
|
||||
onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} />
|
||||
<LevelRow label="VOX" on={st.vox} value={st.vox_gain}
|
||||
onToggle={() => set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))}
|
||||
onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} />
|
||||
<Row label="Anti-VOX">
|
||||
<Slider value={st.anti_vox} disabled={!st.vox} onChange={(v) => set({ anti_vox: v }, () => IcomSetAntiVOX(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.anti_vox}</span>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
||||
<Row label="AF">
|
||||
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||
</Row>
|
||||
<Row label="RF">
|
||||
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||||
</Row>
|
||||
<Row label={t('icmp.squelch')}>
|
||||
<Slider value={st.squelch} onChange={(v) => set({ squelch: v }, () => IcomSetSquelch(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.squelch}</span>
|
||||
</Row>
|
||||
<Row label="AGC">
|
||||
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||
</Row>
|
||||
<Row label={t('icmp.preamp')}>
|
||||
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label="Att">
|
||||
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label={t('icmp.filter')}>
|
||||
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||||
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */}
|
||||
<Card icon={Filter} title={t('icmp.passband')} accent="#7c3aed">
|
||||
<Row label="PBT-IN">
|
||||
<Slider value={st.pbt_inner} accent="#7c3aed" onChange={(v) => set({ pbt_inner: v }, () => IcomSetPBTInner(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50}</span>
|
||||
</Row>
|
||||
<Row label="PBT-OUT">
|
||||
<Slider value={st.pbt_outer} accent="#7c3aed" onChange={(v) => set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}</span>
|
||||
</Row>
|
||||
<button type="button"
|
||||
onClick={() => { set({ pbt_inner: 50 }, () => IcomSetPBTInner(50)); set({ pbt_outer: 50 }, () => IcomSetPBTOuter(50)); }}
|
||||
className="w-full py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
{t('icmp.pbtCenter')}
|
||||
</button>
|
||||
<div className="pt-1 border-t border-border/60 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="MN" on={st.manual_notch} onClick={() => set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} />
|
||||
<Slider value={st.notch_pos} disabled={!st.manual_notch} accent="#7c3aed" onChange={(v) => set({ notch_pos: v }, () => IcomSetNotchPos(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.notch_pos}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('icmp.manualNotch')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
||||
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||||
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||||
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||||
</div>
|
||||
{/* APF (audio peak filter) — CW only: peaks the CW tone. */}
|
||||
{isCW && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="APF" on={st.apf} onClick={() => set({ apf: !st.apf }, () => IcomSetAPF(!st.apf))} />
|
||||
<span className="text-xs text-muted-foreground">{t('icmp.apf')}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
function saveMapView(m: L.Map) {
|
||||
const c = m.getCenter();
|
||||
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
||||
}
|
||||
|
||||
// The Main tab is built from two independent map panes that the operator can
|
||||
// place on either side (Settings → Main view):
|
||||
// • WorldMap ("map1"): a world map with the great-circle path from the
|
||||
// operator to the contacted station, distance, short/long-path azimuth and
|
||||
// the antenna beam lobe.
|
||||
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
|
||||
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
|
||||
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
|
||||
|
||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
||||
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
||||
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
||||
function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
let prev = NaN;
|
||||
for (const [la, lo] of ring) {
|
||||
let lon = lo;
|
||||
if (!Number.isNaN(prev)) {
|
||||
while (lon - prev > 180) lon -= 360;
|
||||
while (lon - prev < -180) lon += 360;
|
||||
}
|
||||
prev = lon;
|
||||
out.push([la, lon]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||
const OSM_ATTR = '© OpenStreetMap contributors';
|
||||
|
||||
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
||||
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
|
||||
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||
};
|
||||
function loadBasemap(): BasemapKey {
|
||||
const v = localStorage.getItem('opslog.mapBasemap');
|
||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||
}
|
||||
|
||||
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||
// layers WebView2 has to hold — is double every other basemap's.
|
||||
function addBasemap(
|
||||
m: L.Map,
|
||||
key: BasemapKey,
|
||||
base: React.MutableRefObject<L.TileLayer | null>,
|
||||
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||
) {
|
||||
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||
const bm = BASEMAPS[key];
|
||||
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
||||
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||
}
|
||||
|
||||
function dot(color: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="display:block;width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.4)"></span>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
});
|
||||
}
|
||||
|
||||
interface WorldProps {
|
||||
fromGrid: string; // operator grid (active profile)
|
||||
toGrid: string; // contacted-station grid
|
||||
fromLabel?: string; // operator callsign
|
||||
toLabel?: string; // DX callsign
|
||||
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
||||
beamWidth?: number; // beamwidth (deg), default 30
|
||||
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
||||
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
|
||||
}
|
||||
|
||||
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | null>(null);
|
||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||
const baseLayer = useRef<L.TileLayer | null>(null);
|
||||
const labelsLayer = useRef<L.TileLayer | null>(null);
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
||||
|
||||
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
|
||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||
// across restarts. Default on.
|
||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||
const autoZoomRef = useRef(autoZoom);
|
||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||
|
||||
// One-time map creation.
|
||||
useEffect(() => {
|
||||
if (worldRef.current && !worldMap.current) {
|
||||
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||
// enough to blow WebView2's raster budget and leave the window painting in
|
||||
// patches. On canvas it is a single layer.
|
||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||
.setView([20, 0], 1);
|
||||
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
worldOverlay.current = L.layerGroup().addTo(m);
|
||||
worldMap.current = m;
|
||||
const sv = loadMapView();
|
||||
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||
}
|
||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||
// the rotor moved a degree.
|
||||
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||
if (worldRef.current) ro.observe(worldRef.current);
|
||||
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||
}, []);
|
||||
|
||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
}, [basemap]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
useEffect(() => {
|
||||
const wm = worldMap.current, wo = worldOverlay.current;
|
||||
if (!wm || !wo) return;
|
||||
wo.clearLayers();
|
||||
|
||||
const from = gridToLatLon(fromGrid);
|
||||
const to = gridToLatLon(toGrid);
|
||||
|
||||
// Station marker + antenna beam/boom are drawn whenever the station grid is
|
||||
// known — independent of any DX. The antenna is always pointed somewhere, so
|
||||
// the beam heading should show even before a callsign is entered.
|
||||
if (from) {
|
||||
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (beamAzimuths && beamAzimuths.length) {
|
||||
const half = (beamWidth ?? 30) / 2;
|
||||
const D = 5500; // lobe length (km)
|
||||
// Great-circle radial out to distance D, stopping just short of the pole
|
||||
// so a poleward line doesn't snap across the top of the Mercator map.
|
||||
const radial = (b: number): [number, number][] => {
|
||||
const out: [number, number][] = [];
|
||||
const N = 64;
|
||||
for (let i = 1; i <= N; i++) {
|
||||
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
||||
out.push([d.lat, d.lon]);
|
||||
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
||||
}
|
||||
return out;
|
||||
};
|
||||
for (const az of beamAzimuths) {
|
||||
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
|
||||
// a filled polygon: a polygon smears badly near the poles on Mercator
|
||||
// (a poleward beam degenerates into a giant triangle), while each radial
|
||||
// LINE stays clean at any azimuth. A small angular step makes the lines
|
||||
// overlap into a solid-looking lobe (no separate strokes), darker near
|
||||
// the antenna and fanning out toward the front.
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
|
||||
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
|
||||
}
|
||||
|
||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||
// Dark casing under the boresight so the bright dashed line stays
|
||||
// readable on any basemap. Same dashArray so the casing tracks each dash.
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
}
|
||||
|
||||
// Mechanical boom (rotor) direction — thin grey dashed line. Drawn when the
|
||||
// Ultrabeam radiates elsewhere (reverse/bi) so the boom heading stays visible
|
||||
// next to the red radiating lobe(s).
|
||||
if (boomAzimuth != null) {
|
||||
const bpts: [number, number][] = [[from.lat, from.lon]];
|
||||
const N = 64, D = 5500;
|
||||
for (let i = 1; i <= N; i++) {
|
||||
const d = destinationPoint(from.lat, from.lon, boomAzimuth, (D * i) / N);
|
||||
bpts.push([d.lat, d.lon]);
|
||||
if (Math.abs(d.lat) > 86) break;
|
||||
}
|
||||
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
||||
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
}
|
||||
|
||||
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
|
||||
let arcPts: [number, number][] | null = null;
|
||||
if (from && to) {
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
|
||||
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
|
||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||
}
|
||||
|
||||
if (autoZoom) {
|
||||
if (from && to && arcPts) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
} else if (to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
} else if (from) {
|
||||
wm.setView([from.lat, from.lon], 3);
|
||||
}
|
||||
}
|
||||
// No invalidateSize() here — a ResizeObserver handles the only case that needs
|
||||
// it. Forcing a full map repaint on every redraw is what made a moving rotor
|
||||
// thrash the compositor.
|
||||
// Headings are rounded in the deps: a rotor reports a jittering float, and a
|
||||
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
|
||||
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
|
||||
|
||||
const path = pathBetween(fromGrid, toGrid);
|
||||
|
||||
return (
|
||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||
<div ref={worldRef} className="absolute inset-0" />
|
||||
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
||||
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
|
||||
title={`Basemap: ${BASEMAPS[k].label}`}
|
||||
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
||||
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
{BASEMAPS[k].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
||||
(remembered across restarts), so the beam heading stays visible. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !autoZoom;
|
||||
setAutoZoom(v);
|
||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||
const m = worldMap.current;
|
||||
if (!v && m) saveMapView(m);
|
||||
}}
|
||||
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
||||
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LocatorProps {
|
||||
toGrid: string; // contacted-station grid
|
||||
toLabel?: string; // DX callsign
|
||||
}
|
||||
|
||||
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
|
||||
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
||||
const locatorRef = useRef<HTMLDivElement>(null);
|
||||
const locatorMap = useRef<L.Map | null>(null);
|
||||
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (locatorRef.current && !locatorMap.current) {
|
||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||
.setView([20, 0], 2);
|
||||
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||
locatorMap.current = m;
|
||||
}
|
||||
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lm = locatorMap.current, lo = locatorOverlay.current;
|
||||
if (!lm || !lo) return;
|
||||
lo.clearLayers();
|
||||
const to = gridToLatLon(toGrid);
|
||||
if (to) {
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(lo);
|
||||
const b = gridSquareBounds(toGrid);
|
||||
if (b) {
|
||||
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
|
||||
}
|
||||
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
|
||||
}
|
||||
setTimeout(() => { lm.invalidateSize(); }, 0);
|
||||
}, [toGrid, toLabel]);
|
||||
|
||||
return (
|
||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||
<div ref={locatorRef} className="absolute inset-0" />
|
||||
{!gridToLatLon(toGrid) && (
|
||||
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
||||
Enter a grid or look up the callsign to center the map.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { QSOForm } from '@/types';
|
||||
import {
|
||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||
WorkedBefore,
|
||||
} from '@/../wailsjs/go/main/App';
|
||||
import { netctl } from '@/../wailsjs/go/models';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
type Net = netctl.Net;
|
||||
type Station = netctl.Station;
|
||||
|
||||
function fmtTimeOn(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||
|
||||
type Props = {
|
||||
onLogged?: () => void;
|
||||
countries?: string[];
|
||||
bands?: string[];
|
||||
modes?: string[];
|
||||
};
|
||||
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [nets, setNets] = useState<Net[]>([]);
|
||||
const [selId, setSelId] = useState<string>('');
|
||||
const [openId, setOpenId] = useState<string>('');
|
||||
const [roster, setRoster] = useState<Station[]>([]);
|
||||
const [active, setActive] = useState<QSOForm[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||
|
||||
// Add/edit-contact dialog.
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
const [contact, setContact] = useState<Station>(emptyStation());
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
const activeGrid = useRef<any>(null);
|
||||
const rosterGrid = useRef<any>(null);
|
||||
|
||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||
const [wbCall, setWbCall] = useState('');
|
||||
const [wb, setWb] = useState<any>(null);
|
||||
const [wbBusy, setWbBusy] = useState(false);
|
||||
const wbReq = useRef(0);
|
||||
// Resizable height (drag the handle on top). Persisted.
|
||||
const [wbHeight, setWbHeight] = useState(() => {
|
||||
const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10);
|
||||
return v >= 80 && v <= 600 ? v : 176;
|
||||
});
|
||||
useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]);
|
||||
const startWbResize = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY, startH = wbHeight;
|
||||
const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY))));
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => {
|
||||
const c = (call ?? '').trim().toUpperCase();
|
||||
setWbCall(c);
|
||||
if (!c) { setWb(null); return; }
|
||||
const req = ++wbReq.current;
|
||||
setWbBusy(true);
|
||||
try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); }
|
||||
catch { if (wbReq.current === req) setWb(null); }
|
||||
finally { if (wbReq.current === req) setWbBusy(false); }
|
||||
}, []);
|
||||
|
||||
const isOpen = openId !== '' && openId === selId;
|
||||
|
||||
const refreshNets = useCallback(async () => {
|
||||
try {
|
||||
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
|
||||
const arr = (list ?? []) as Net[];
|
||||
setNets(arr);
|
||||
setOpenId(oid ?? '');
|
||||
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
|
||||
const refreshRoster = useCallback(async (id: string) => {
|
||||
if (!id) { setRoster([]); return; }
|
||||
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
|
||||
const refreshActive = useCallback(async () => {
|
||||
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
|
||||
catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||
|
||||
// The roster side hides callsigns currently on the air (they live in the left
|
||||
// grid until logged), mirroring Log4OM's two-list behaviour.
|
||||
const activeCalls = useMemo(
|
||||
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
|
||||
[active],
|
||||
);
|
||||
const rosterShown = useMemo(
|
||||
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
||||
[roster, activeCalls],
|
||||
);
|
||||
|
||||
async function newNet() {
|
||||
const name = window.prompt(t('ncp.newNetPrompt'));
|
||||
if (!name?.trim()) return;
|
||||
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function renameNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
const name = window.prompt(t('ncp.renamePrompt'), cur?.name ?? '');
|
||||
if (!name?.trim()) return;
|
||||
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function deleteNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
if (!window.confirm(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) return;
|
||||
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function toggleOpen() {
|
||||
try {
|
||||
if (isOpen) {
|
||||
if (active.length > 0 &&
|
||||
!window.confirm(t('ncp.closeConfirm', { n: active.length }))) return;
|
||||
await NetClose(); setOpenId('');
|
||||
} else {
|
||||
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
||||
}
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Roster → active (start QSO).
|
||||
async function activate(call: string) {
|
||||
if (!isOpen || !call) return;
|
||||
try { await NetActivate(call); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||
async function deactivate(id?: number) {
|
||||
if (id == null) return;
|
||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||
async function saveDraft(q: QSOForm) {
|
||||
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function discardDraft(id: number) {
|
||||
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Add-contact dialog.
|
||||
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
|
||||
async function lookupContact() {
|
||||
const call = (contact.callsign ?? '').trim();
|
||||
if (!call) return;
|
||||
setLooking(true);
|
||||
try {
|
||||
const r = await NetLookup(call);
|
||||
setContact((c) => netctl.Station.createFrom({
|
||||
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
||||
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
||||
grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state,
|
||||
cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon,
|
||||
email: r.email || c.email,
|
||||
}));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setLooking(false); }
|
||||
}
|
||||
async function saveContact() {
|
||||
const call = (contact.callsign ?? '').trim().toUpperCase();
|
||||
if (!call || !selId) return;
|
||||
try {
|
||||
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
|
||||
setContactOpen(false);
|
||||
await refreshRoster(selId);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeSelectedRoster() {
|
||||
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
||||
if (sel.length === 0) return;
|
||||
if (!window.confirm(t('ncp.removeConfirm', { n: sel.length }))) return;
|
||||
try {
|
||||
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
||||
await refreshRoster(selId);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
||||
], [t]);
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||
], [t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||
<Plus className="size-3.5" /> {t('ncp.newNet')}
|
||||
</Button>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||
value={selId}
|
||||
disabled={isOpen}
|
||||
onChange={(e) => setSelId(e.target.value)}
|
||||
title={isOpen ? t('ncp.closeToSwitch') : t('ncp.selectNetTitle')}
|
||||
>
|
||||
<option value="">{t('ncp.selectNetOption')}</option>
|
||||
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||
</select>
|
||||
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-danger" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
{isOpen && <Badge className="bg-success text-success-foreground tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
||||
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
||||
</span>
|
||||
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* ACTIVE USERS (left) */}
|
||||
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{t('ncp.onAirActive')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<QSOForm>
|
||||
ref={activeGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={active}
|
||||
columnDefs={activeCols}
|
||||
defaultColDef={defaultColDef}
|
||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && active.length > 0 && (
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worked-before for the clicked station — click a row (on air or
|
||||
roster) to see if/when we contacted it before. Lives INSIDE the
|
||||
on-air column so resizing only splits this column; the roster on
|
||||
the right keeps its full height. */}
|
||||
<div className="shrink-0 flex flex-col" style={{ height: wbHeight }}>
|
||||
<div className="h-1.5 shrink-0 cursor-ns-resize border-t border-border/60 hover:bg-primary/40 transition-colors" onMouseDown={startWbResize} title={t('ncp.wbResize')} />
|
||||
<div className="flex-1 min-h-0 bg-card flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
<History className="size-3.5" />
|
||||
{t('ncp.workedBefore')}
|
||||
{wbCall && <span className="font-mono text-foreground normal-case">{wbCall}</span>}
|
||||
{wb && wb.count > 0 && (
|
||||
<span className="ml-auto font-normal normal-case text-foreground">
|
||||
{wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)}
|
||||
{wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{!wbCall ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||
) : wbBusy ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">…</div>
|
||||
) : !wb || wb.count === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
||||
<tr className="text-left">
|
||||
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
||||
<th className="px-2 py-1 font-semibold">RST</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{((wb.entries ?? []) as any[]).map((q, i) => (
|
||||
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
||||
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
||||
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
||||
<td className="px-2 py-1">{q.band}</td>
|
||||
<td className="px-2 py-1">{q.mode}</td>
|
||||
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
||||
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NET USERS / roster (right) */}
|
||||
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{t('ncp.netUsersRoster')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<Station>
|
||||
ref={rosterGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={rosterShown}
|
||||
columnDefs={rosterCols}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).callsign)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-danger" disabled={!selId} onClick={removeSelectedRoster}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
||||
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||
<PlusCircle className="size-3.5" /> {t('ncp.putOnAir')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
|
||||
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
|
||||
{editingDraft && (
|
||||
<QSOEditModal
|
||||
qso={editingDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={discardDraft}
|
||||
onClose={() => setEditingDraft(null)}
|
||||
countries={countries}
|
||||
bands={bands}
|
||||
modes={modes}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add / edit contact dialog */}
|
||||
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('ncp.addContactTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('ncp.addContactDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 px-5 py-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[11px]">{t('ncp.callsign')}</Label>
|
||||
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||
<Search className="size-3.5" /> {looking ? '…' : t('ncp.search')}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">{t('ncp.name')}</Label>
|
||||
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">QTH</Label>
|
||||
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">{t('ncp.country')}</Label>
|
||||
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>{t('ncp.cancel')}</Button>
|
||||
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||
<PlusCircle className="size-3.5" /> {t('ncp.saveInNet')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Band = { band: string; is_default: boolean };
|
||||
type Antenna = {
|
||||
@@ -38,6 +39,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function OperatingPanel({ bands, onError }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [tree, setTree] = useState<Station[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// expanded keeps which stations show their antennas; everything open by
|
||||
@@ -131,7 +133,7 @@ export function OperatingPanel({ bands, onError }: Props) {
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeAntenna(stationId: number, antId: number) {
|
||||
if (!confirm('Delete this antenna?')) return;
|
||||
if (!confirm(t('op.deleteAntConfirm'))) return;
|
||||
try {
|
||||
await DeleteOperatingAntenna(antId);
|
||||
setTree((prev) => prev.map((s) =>
|
||||
@@ -149,23 +151,18 @@ export function OperatingPanel({ bands, onError }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
Define your rigs (stations) and the antennas connected to each one.
|
||||
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
|
||||
the default antenna for that band — when you change the band in the
|
||||
entry strip, the matching rig + antenna auto-fill the MY_RIG and
|
||||
MY_ANTENNA ADIF fields. Only one antenna can be the default per
|
||||
band; setting one clears the previous default.
|
||||
{t('op.intro1')}<Star className="inline size-3 text-warning fill-current align-text-bottom" />{t('op.intro2')}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={addStation}>
|
||||
<Plus className="size-3.5" /> Add rig
|
||||
<Plus className="size-3.5" /> {t('op.addRig')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
|
||||
No rig configured yet. Click "Add rig" to get started.
|
||||
{t('op.noRig')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -212,6 +209,7 @@ function StationRow({
|
||||
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
||||
onUpdateAntenna, onDeleteAntenna,
|
||||
}: StationRowProps) {
|
||||
const { t } = useI18n();
|
||||
const editKey = `station:${station.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({
|
||||
@@ -242,7 +240,7 @@ function StationRow({
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
className="p-0.5 hover:bg-accent/40 rounded"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? t('op.collapse') : t('op.expand')}
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
@@ -253,12 +251,12 @@ function StationRow({
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Rig name (also stamped as MY_RIG)"
|
||||
placeholder={t('op.rigNamePh')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
|
||||
<Label className="text-[11px] text-muted-foreground">{t('op.powerW')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -269,8 +267,8 @@ function StationRow({
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commit}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
<Button size="sm" onClick={commit}>{t('btn.save')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -279,13 +277,13 @@ function StationRow({
|
||||
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title={t('op.edit')}>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
||||
<Plus className="size-3" /> Antenna
|
||||
<Plus className="size-3" /> {t('op.antenna')}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title={t('op.deleteRig')}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -296,7 +294,7 @@ function StationRow({
|
||||
<div className="p-2 space-y-2">
|
||||
{(station.antennas ?? []).length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic pl-6">
|
||||
No antenna yet — click "Antenna" above to add one.
|
||||
{t('op.noAntenna')}
|
||||
</div>
|
||||
) : (
|
||||
(station.antennas ?? []).map((a) => (
|
||||
@@ -329,6 +327,7 @@ type AntennaRowProps = {
|
||||
};
|
||||
|
||||
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
||||
const { t } = useI18n();
|
||||
const editKey = `antenna:${antenna.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({ name: antenna.name });
|
||||
@@ -377,13 +376,13 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Antenna name (also stamped as MY_ANTENNA)"
|
||||
placeholder={t('op.antNamePh')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commitNames}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
<Button size="sm" onClick={commitNames}>{t('btn.save')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -412,7 +411,7 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
||||
isDefault
|
||||
? 'border-amber-400 bg-amber-50 shadow-sm'
|
||||
? 'border-warning-border bg-warning-muted shadow-sm'
|
||||
: enabled
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||
@@ -431,14 +430,14 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
||||
isDefault
|
||||
? 'bg-amber-400 text-white'
|
||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-warning hover:text-warning',
|
||||
)}
|
||||
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
|
||||
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
|
||||
>
|
||||
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider">
|
||||
{isDefault ? 'Default' : 'Set'}
|
||||
{isDefault ? t('op.default') : t('op.set')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,36 +1,90 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UploadCloud, Search, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||
import { AllCommunityModule, ModuleRegistry, type ColDef } from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
type UploadRow = {
|
||||
id: number; qso_date: string; callsign: string;
|
||||
band: string; mode: string; country: string; status: string;
|
||||
};
|
||||
|
||||
const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||
{ field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 },
|
||||
{ field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 },
|
||||
{ field: 'band', headerName: 'Band', width: 90 },
|
||||
{ field: 'mode', headerName: 'Mode', width: 100 },
|
||||
{ field: 'country', headerName: 'Country', flex: 1, minWidth: 140 },
|
||||
{ field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' },
|
||||
];
|
||||
|
||||
type Confirmation = {
|
||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
{ v: 'qrz', label: 'QRZ.com' },
|
||||
{ v: 'clublog', label: 'Club Log' },
|
||||
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||
{ v: 'lotw', label: 'LoTW' },
|
||||
{ v: 'pota', label: 'POTA hunter log' },
|
||||
{ v: 'paper', label: 'Paper QSL' },
|
||||
];
|
||||
|
||||
// Sent-status filter values. Empty string = blank/none.
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
{ v: '_', label: 'qslm.leave' },
|
||||
{ v: 'Y', label: 'qslm.yes' },
|
||||
{ v: 'N', label: 'qslm.no' },
|
||||
{ v: 'R', label: 'qslm.requested' },
|
||||
{ v: 'I', label: 'qslm.ignore' },
|
||||
];
|
||||
|
||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
||||
const QSL_VIA_OPTIONS = [
|
||||
{ v: '_', label: 'qslm.leave' },
|
||||
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
||||
{ v: 'Direct', label: 'qslm.viaDirect' },
|
||||
{ v: 'Electronic', label: 'qslm.viaElectronic' },
|
||||
];
|
||||
|
||||
// Maps a service value → its i18n label key (only for services with
|
||||
// translatable names; brand names like QRZ.com stay as-is in SERVICES).
|
||||
const SERVICE_LABEL_KEYS: Record<string, string> = {
|
||||
pota: 'qslm.svcPota',
|
||||
paper: 'qslm.svcPaper',
|
||||
};
|
||||
|
||||
type LogQSO = {
|
||||
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
||||
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
||||
};
|
||||
|
||||
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
||||
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
|
||||
|
||||
// label holds an i18n key.
|
||||
const SENT_STATUSES = [
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'N', label: 'No' },
|
||||
{ v: 'Q', label: 'Queued' },
|
||||
{ v: 'Y', label: 'Yes (already sent)' },
|
||||
{ v: 'I', label: 'Invalid' },
|
||||
{ v: '_', label: '— blank —' },
|
||||
{ v: 'R', label: 'qslm.sentRequested' },
|
||||
{ v: 'N', label: 'qslm.sentNo' },
|
||||
{ v: 'Q', label: 'qslm.sentQueued' },
|
||||
{ v: 'Y', label: 'qslm.sentYes' },
|
||||
{ v: 'I', label: 'qslm.sentInvalid' },
|
||||
{ v: '_', label: 'qslm.sentBlank' },
|
||||
];
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
@@ -41,193 +95,508 @@ function fmtDate(iso: string): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
// fmtQslDate renders a QSL/LoTW/eQSL/ClubLog date (ADIF YYYYMMDD, or an ISO
|
||||
// datetime) as YYYY-MM-DD — same shape as the QSO date, without the time.
|
||||
export function fmtQslDate(s?: string): string {
|
||||
if (!s) return '';
|
||||
const t = s.trim();
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(t);
|
||||
if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10);
|
||||
return t;
|
||||
}
|
||||
|
||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [service, setService] = useState('lotw');
|
||||
// The callsign this profile signs/uploads/downloads as for the selected
|
||||
// service (Force station callsign, else the profile call). Shown so the user
|
||||
// knows WHICH of their calls a download/upload targets in a mixed-call log.
|
||||
const [uploadCall, setUploadCall] = useState('');
|
||||
useEffect(() => {
|
||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||
}, [service]);
|
||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||
const [potaErr, setPotaErr] = useState('');
|
||||
const [potaAddMissing, setPotaAddMissing] = useState(false);
|
||||
// Only sync hunts made under the active profile's callsign (skip XV9Q/NQ2H…).
|
||||
const [potaOnlyMyCall, setPotaOnlyMyCall] = useState(true);
|
||||
|
||||
async function syncPota() {
|
||||
setPotaSyncing(true); setPotaErr(''); setPotaRes(null);
|
||||
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing, potaOnlyMyCall)) as any as POTASync); }
|
||||
catch (e: any) { setPotaErr(String(e?.message ?? e)); }
|
||||
finally { setPotaSyncing(false); }
|
||||
}
|
||||
|
||||
// ── Paper QSL: search a callsign, bulk-set sent/received + via + date ──
|
||||
const [paperCall, setPaperCall] = useState('');
|
||||
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
||||
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
||||
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
|
||||
const [paperBusy, setPaperBusy] = useState(false);
|
||||
const [paperMsg, setPaperMsg] = useState('');
|
||||
const [qslRcvd, setQslRcvd] = useState('N');
|
||||
const [qslSent, setQslSent] = useState('_');
|
||||
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||
const [qslSentDate, setQslSentDate] = useState('');
|
||||
const [qslVia, setQslVia] = useState('_');
|
||||
const [qslNotes, setQslNotes] = useState('');
|
||||
const [qslComment, setQslComment] = useState('');
|
||||
|
||||
const searchPaper = useCallback(async () => {
|
||||
const c = paperCall.trim().toUpperCase();
|
||||
if (!c) return;
|
||||
setPaperBusy(true); setPaperMsg('');
|
||||
try {
|
||||
const r: any = await ListQSO({ callsign: c, limit: 1000 } as any);
|
||||
const list = (r ?? []) as LogQSO[];
|
||||
setPaperRows(list);
|
||||
setPaperSel(new Set(list.map((x) => x.id)));
|
||||
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
|
||||
|
||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
|
||||
finally { setPaperBusy(false); }
|
||||
}, [paperCall]);
|
||||
|
||||
|
||||
async function applyPaper() {
|
||||
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
||||
if (ids.length === 0) return;
|
||||
setPaperBusy(true); setPaperMsg('');
|
||||
const ymd = (d: string) => d.replaceAll('-', '');
|
||||
try {
|
||||
const n = await BulkUpdateQSL(ids as any, {
|
||||
sent_status: qslSent === '_' ? '' : qslSent,
|
||||
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
||||
sent_date: ymd(qslSentDate),
|
||||
rcvd_date: ymd(qslRcvdDate),
|
||||
via: qslVia === '_' ? '' : qslVia,
|
||||
notes: qslNotes,
|
||||
comment: qslComment,
|
||||
} as any);
|
||||
setPaperMsg(t('qslm.qsoUpdated', { n }));
|
||||
await searchPaper();
|
||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
||||
finally { setPaperBusy(false); }
|
||||
}
|
||||
const [sent, setSent] = useState('R');
|
||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
// Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
|
||||
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [addNotFound, setAddNotFound] = useState(false);
|
||||
// Download date window: 'last' = incremental since last pull, 'date' = from a
|
||||
// chosen date, 'all' = everything.
|
||||
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
|
||||
const [sinceDate, setSinceDate] = useState('');
|
||||
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
|
||||
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
||||
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
|
||||
|
||||
const [showLog, setShowLog] = useState(false);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [uploadDone, setUploadDone] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
|
||||
useEffect(() => {
|
||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} uploaded —`]);
|
||||
setUploadDone(true);
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||
setBusy(false);
|
||||
});
|
||||
return () => { offLog(); offDone(); };
|
||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||
setConfirmations((list ?? []) as Confirmation[]);
|
||||
setViewMode('confirmations');
|
||||
});
|
||||
return () => { offLog(); offDone(); offConf(); };
|
||||
}, []);
|
||||
|
||||
const selectedCount = selected.size;
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
async function selectRequired() {
|
||||
// Grid selection → just track the count; ids are read from the grid at upload.
|
||||
function onUploadRowSelected(ids: number[]) {
|
||||
setUploadSelIds(ids);
|
||||
setSelectedCount(ids.length);
|
||||
}
|
||||
|
||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||
switch (confFilter) {
|
||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||
case 'dxcc': return c.new_dxcc;
|
||||
case 'band': return c.new_band;
|
||||
case 'slot': return c.new_slot;
|
||||
default: return true;
|
||||
}
|
||||
}), [confirmations, confFilter]);
|
||||
|
||||
const selectRequired = useCallback(async () => {
|
||||
setSearching(true);
|
||||
setError('');
|
||||
try {
|
||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||
const list = (r ?? []) as UploadRow[];
|
||||
const list = (r ?? []) as any[];
|
||||
setRows(list);
|
||||
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
|
||||
setUploadSelIds(list.map((x) => x.id));
|
||||
setSelectedCount(list.length);
|
||||
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
|
||||
setViewMode('upload');
|
||||
setShowLog(false);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
setRows([]);
|
||||
setSelected(new Set());
|
||||
setSelectedCount(0);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number) {
|
||||
setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(id)) n.delete(id); else n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
function toggleAll() {
|
||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
}
|
||||
}, [service, sent]);
|
||||
|
||||
async function upload() {
|
||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
||||
const ids = uploadSelIds;
|
||||
if (ids.length === 0) return;
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await UploadQSOsManual(service, ids);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
|
||||
setUploadDone(true);
|
||||
}
|
||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||
try { await UploadQSOsManual(service, ids); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
function closeLog() {
|
||||
setLogOpen(false);
|
||||
// Refresh the list so uploaded QSOs drop out of the current filter.
|
||||
selectRequired();
|
||||
async function download() {
|
||||
// Resolve the date window into the backend's `since` argument:
|
||||
// 'all' → "" (everything)
|
||||
// 'last' → "last" (incremental since last successful pull)
|
||||
// 'date' → "YYYY-MM-DD" (the chosen date; falls back to all if empty)
|
||||
const since = sinceMode === 'last' ? 'last' : sinceMode === 'date' ? sinceDate.trim() : '';
|
||||
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||
try { await DownloadConfirmations(service, addNotFound, since); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
function viewResults() {
|
||||
setShowLog(false);
|
||||
if (logAction === 'upload') selectRequired();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>QSL Manager</DialogTitle>
|
||||
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rows.length} found · {selectedCount} selected
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.service')}</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{SERVICE_LABEL_KEYS[s.v] ? t(SERVICE_LABEL_KEYS[s.v]) : s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{uploadCall && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||
<span
|
||||
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||
title={t('qslm.callsignScopeTitle')}
|
||||
>
|
||||
{uploadCall}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{service === 'pota' ? (
|
||||
<>
|
||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||
{t('qslm.syncHunterLog')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.onlyMyCallTitle')}>
|
||||
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
||||
{t('qslm.onlyMyCall')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.addMissingTitle')}>
|
||||
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||
{t('qslm.addMissing')}
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.potaToken')}</span>
|
||||
</>
|
||||
) : service === 'paper' ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
|
||||
onChange={(e) => setPaperCall(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
||||
placeholder={t('qslm.callsignPlaceholder')} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
|
||||
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
{t('qslm.search')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.sentStatus')}</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
{t('qslm.selectRequired')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{service === 'pota' && potaRes && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||
</span>
|
||||
)}
|
||||
{service === 'paper' && paperRows.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{t('qslm.paperCount', { total: paperRows.length, selected: paperSel.size })}</span>
|
||||
)}
|
||||
{!showLog && viewMode === 'confirmations' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.filter')}</label>
|
||||
<Select value={confFilter} onValueChange={setConfFilter}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('qslm.filterAll')}</SelectItem>
|
||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{logLines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
||||
{showLog ? <><ListChecks className="size-3.5" /> {t('qslm.results')}</> : <><ScrollText className="size-3.5" /> {t('qslm.log')}</>}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewMode === 'confirmations'
|
||||
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
|
||||
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results grid */}
|
||||
<div className="overflow-auto px-4 py-2 min-h-[200px]">
|
||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
Pick a service + sent status, then “Select required”.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th>
|
||||
<th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th>
|
||||
<th className="py-1.5 px-2">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => toggle(r.id)}
|
||||
>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||
<td className="py-1 px-2">{r.band}</td>
|
||||
<td className="py-1 px-2">{r.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Content: log OR results grid */}
|
||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||
{error && <div className="text-xs text-danger mb-2">{error}</div>}
|
||||
|
||||
{service === 'paper' ? (
|
||||
paperRows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.paperEmpty')}</div>
|
||||
) : (
|
||||
// Same grid as Recent QSOs (full columns + column picker). Selection
|
||||
// drives which QSOs the apply-form below updates; a search selects all.
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
rows={paperRows as any}
|
||||
total={paperRows.length}
|
||||
selectAllSignal={paperSelAllSig}
|
||||
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : service === 'pota' ? (
|
||||
<div className="space-y-3">
|
||||
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
|
||||
{!potaRes && !potaErr && !potaSyncing && (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.potaEmpty')}</div>
|
||||
)}
|
||||
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
|
||||
{potaRes && (
|
||||
<>
|
||||
<div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
|
||||
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
|
||||
{potaRes.skipped_other_call > 0 && (
|
||||
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
||||
)}
|
||||
{' '}{t('qslm.potaRescan')}
|
||||
</div>
|
||||
{potaRes.unmatched_list?.length > 0 && (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">{t('qslm.thActivator')}</th><th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thPark')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thWhyUnmatched')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{potaRes.unmatched_list.map((u, i) => (
|
||||
<tr key={i}
|
||||
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
|
||||
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
|
||||
title={u.qso_id > 0 ? t('qslm.openToFix') : ''}>
|
||||
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
|
||||
<td className="py-1 px-2 font-mono">{u.date}</td>
|
||||
<td className="py-1 px-2">{u.band}</td>
|
||||
<td className="py-1 px-2 font-mono">{u.reference}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">
|
||||
{u.reason}
|
||||
{u.qso_id > 0 && <ExternalLink className="inline size-3 ml-1 opacity-60" />}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
|
||||
<UploadCloud className="size-3.5" />
|
||||
Upload {selectedCount} to {serviceLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Upload progress / log window */}
|
||||
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Uploading to {serviceLabel}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Upload progress log.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
|
||||
) : showLog ? (
|
||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||
{logLines.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
|
||||
) : logLines.map((l, i) => (
|
||||
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div>
|
||||
<div key={i} className={cn(
|
||||
/FAIL|failed|error/i.test(l) ? 'text-danger'
|
||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-success'
|
||||
: 'text-foreground/90')}>{l}</div>
|
||||
))}
|
||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
|
||||
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading…</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : viewMode === 'confirmations' ? (
|
||||
shownConfs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
{confirmations.length === 0 ? t('qslm.noNewConf') : t('qslm.noConfMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shownConfs.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||
<td className="py-1 px-2">{c.band}</td>
|
||||
<td className="py-1 px-2">{c.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.uploadEmpty')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
rows={rows as any}
|
||||
total={rows.length}
|
||||
selectAllSignal={uploadSelAllSig}
|
||||
onRowSelected={onUploadRowSelected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paper-QSL apply form */}
|
||||
{service === 'paper' && (
|
||||
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslSent')}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select value={qslSent} onValueChange={setQslSent}>
|
||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
|
||||
<Select value={qslVia} onValueChange={setQslVia}>
|
||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
||||
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.comment')}</label>
|
||||
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
||||
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
||||
{t('qslm.applyToSelected', { n: paperSel.size })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
|
||||
{service !== 'pota' && service !== 'paper' && (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title={t('qslm.downloadTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||
</Button>
|
||||
{/* Date window */}
|
||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="last">{t('qslm.sinceLast')}</SelectItem>
|
||||
<SelectItem value="date">{t('qslm.sinceDate')}</SelectItem>
|
||||
<SelectItem value="all">{t('qslm.sinceAll')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sinceMode === 'date' && (
|
||||
<input
|
||||
type="date"
|
||||
value={sinceDate}
|
||||
onChange={(e) => setSinceDate(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
title={service === 'qrz' ? t('qslm.sinceDateTitleQrz') : t('qslm.sinceDateTitleLotw')}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.addNotFoundTitle')}>
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
{t('qslm.addNotFound')}
|
||||
</label>
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
|
||||
type Props = {
|
||||
menu: QSOMenuState;
|
||||
onClose: () => void;
|
||||
onUpdateFromCty: (ids: number[]) => void;
|
||||
onUpdateFromQRZ: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
{ service: 'qrz', name: 'QRZ.com' },
|
||||
{ service: 'clublog', name: 'Club Log' },
|
||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||
{ service: 'lotw', name: 'LoTW' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||
// onCellContextMenu. Stays open until the user clicks outside, presses Escape,
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('mousedown', close);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
if (!menu) return null;
|
||||
const n = menu.ids.length;
|
||||
// Keep the menu on-screen near the cursor.
|
||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: x, top: y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('qctx.selected', { n })}
|
||||
</div>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||
>
|
||||
<Globe2 className="size-4 text-primary" />
|
||||
<span>{t('qctx.fixCountry')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||
>
|
||||
<RefreshCw className="size-4 text-info" />
|
||||
<span>{t('qctx.updateQrz')}</span>
|
||||
</button>
|
||||
{onUpdateFromClublog && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||
>
|
||||
<BadgeCheck className="size-4 text-info" />
|
||||
<span>{t('qctx.updateClublog')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(onSendRecording || onSendEQSL) && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{onSendEQSL && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||
>
|
||||
<Mail className="size-4 text-warning" />
|
||||
<span>{t('qctx.sendQslEmail')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onSendRecording && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
||||
>
|
||||
<Mail className="size-4 text-danger" />
|
||||
<span>{t('qctx.sendRecording')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onBulkEdit && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||
>
|
||||
<PencilLine className="size-4 text-info" />
|
||||
<span>{t('qctx.bulkEdit', { n })}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(onExportSelected || onExportFiltered) && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{onExportSelected && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportFiltered && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloSelected && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-warning" />
|
||||
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloFiltered && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-warning" />
|
||||
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onSendTo && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{UPLOAD_TARGETS.map((target) => (
|
||||
<button
|
||||
key={target.service}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
||||
>
|
||||
<Upload className="size-4 text-success" />
|
||||
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-danger hover:bg-danger-muted"
|
||||
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span>{t('qctx.delete', { n })}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -12,33 +16,109 @@ import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { QSOForm } from '@/types';
|
||||
|
||||
type QSO = QSOForm;
|
||||
|
||||
// Quick prefix from a callsign (drops portable suffixes, keeps a slashed
|
||||
// prefix). Read-only display, mirrors Log4OM's PFX box.
|
||||
function pfxOf(call: string): string {
|
||||
const c = (call || '').trim().toUpperCase();
|
||||
if (!c) return '';
|
||||
const base = c.includes('/') ? c.split('/')[0] : c;
|
||||
let lastDigit = -1;
|
||||
for (let i = 0; i < base.length; i++) if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
|
||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||
}
|
||||
|
||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
{ value: '_', label: '—' },
|
||||
{ value: 'Y', label: 'Yes' },
|
||||
{ value: 'N', label: 'No' },
|
||||
{ value: 'R', label: 'Requested' },
|
||||
{ value: 'I', label: 'Ignore' },
|
||||
{ value: '_', label: 'qedit.qslDash' },
|
||||
{ value: 'Y', label: 'qedit.qslYes' },
|
||||
{ value: 'N', label: 'qedit.qslNo' },
|
||||
{ value: 'R', label: 'qedit.qslRequested' },
|
||||
{ value: 'I', label: 'qedit.qslIgnore' },
|
||||
];
|
||||
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
|
||||
// Confirmation channels — each maps to its QSO sent/received status, dates and
|
||||
// (paper-only) via fields. Drives the "Manage Confirmation" editor and the
|
||||
// live status grid (Log4OM style).
|
||||
type ConfDef = {
|
||||
key: string; label: string;
|
||||
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
||||
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
||||
via?: keyof QSOForm;
|
||||
};
|
||||
const CONFIRMATIONS: ConfDef[] = [
|
||||
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
|
||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||
];
|
||||
// i18n label keys for confirmation channels whose label has translatable words
|
||||
// (brand names like LoTW/eQSL stay as their literal label in CONFIRMATIONS).
|
||||
const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
QSL: 'qedit.confQslPaper',
|
||||
};
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
const v = (value || '').toUpperCase();
|
||||
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
||||
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
||||
if (v === '') {
|
||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||
}
|
||||
// One colour per state, and each colour means something:
|
||||
// Yes green — confirmed, the thing you wanted
|
||||
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||
// This is the ONLY state asking for action, so it gets the
|
||||
// only alarming colour.
|
||||
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||||
// every row; painting that orange (as it used to be, in the
|
||||
// same orange as Requested) made the table shout about a
|
||||
// non-problem and told you nothing apart.
|
||||
// Ignore dashed — deliberately excluded, on purpose.
|
||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
||||
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||
: 'bg-muted text-muted-foreground border border-border';
|
||||
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
qso: QSO;
|
||||
onSave: (q: QSO) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onClose: () => void;
|
||||
countries?: string[];
|
||||
bands?: string[];
|
||||
modes?: string[];
|
||||
}
|
||||
|
||||
function toLocalISO(d: any): string {
|
||||
if (!d) return '';
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
|
||||
// doesn't apply to a time struct), so a QSO with no end time arrives as a
|
||||
// year-1 date. Treat anything that old as unset — otherwise the datetime
|
||||
// field shows a garbage value and fights the user's typing.
|
||||
if (date.getUTCFullYear() <= 1) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
||||
}
|
||||
@@ -48,23 +128,6 @@ function parseLocalISO(s: string): string | null {
|
||||
if (!m) return null;
|
||||
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
|
||||
}
|
||||
function stringifyExtras(e?: Record<string, string>): string {
|
||||
if (!e) return '';
|
||||
return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n');
|
||||
}
|
||||
function parseExtras(t: string): Record<string, string> | undefined {
|
||||
const out: Record<string, string> = {};
|
||||
for (const raw of t.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx < 0) continue;
|
||||
const k = line.slice(0, idx).trim().toUpperCase();
|
||||
const v = line.slice(idx + 1).trim();
|
||||
if (k && v) out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function numOrUndef(v: any): number | undefined {
|
||||
if (v === '' || v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||
@@ -85,32 +148,158 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
||||
}
|
||||
|
||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||||
// imported/legacy value is never silently dropped from the dropdown.
|
||||
const bandList = useMemo(() => {
|
||||
const base = (bands && bands.length ? bands : BANDS).slice();
|
||||
if (qso.band && !base.includes(qso.band)) base.unshift(qso.band);
|
||||
return base;
|
||||
}, [bands, qso.band]);
|
||||
const modeList = useMemo(() => {
|
||||
const base = (modes && modes.length ? modes : MODES).slice();
|
||||
if (qso.mode && !base.includes(qso.mode)) base.unshift(qso.mode);
|
||||
return base;
|
||||
}, [modes, qso.mode]);
|
||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||
const [freqMhz, setFreqMhz] = useState(draft.freq_hz ? String(draft.freq_hz / 1_000_000) : '');
|
||||
const [freqRxMhz, setFreqRxMhz] = useState(draft.freq_rx_hz ? String(draft.freq_rx_hz / 1_000_000) : '');
|
||||
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||
const splitHz = (hz?: number) => hz
|
||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||
: { khz: '', hz: '' };
|
||||
const f0 = splitHz(draft.freq_hz);
|
||||
const fr0 = splitHz(draft.freq_rx_hz);
|
||||
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||
const [freqHz, setFreqHz] = useState(f0.hz);
|
||||
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
||||
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
||||
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
||||
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
||||
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
||||
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
|
||||
const [dateOff, setDateOff] = useState(dateOffISO);
|
||||
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
|
||||
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
||||
const [localErr, setLocalErr] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
// === Award references (Log4OM-style tab) ===
|
||||
// Manual refs are edited as a "CODE@REF;…" string; computed refs (DXCC, WAZ,
|
||||
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||
const awardFieldRef = useRef<Record<string, string>>({});
|
||||
const [awardRefs, setAwardRefs] = useState('');
|
||||
// The refs present when the editor opened, so on save we can tell which ones
|
||||
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
|
||||
const seedAwardRefsRef = useRef('');
|
||||
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||
|
||||
// Load award definitions once, then seed the editable manual refs from the QSO.
|
||||
useEffect(() => {
|
||||
GetAwardDefs()
|
||||
.then(async (defs) => {
|
||||
const list = (defs ?? []) as any[];
|
||||
const fieldOf: Record<string, string> = {};
|
||||
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
|
||||
awardFieldRef.current = fieldOf;
|
||||
// Seed the editable manual refs from the backend, which already matched
|
||||
// each reference against its award's own list. Seeding from the raw QSO
|
||||
// field instead would wrongly seed every state-award (WAS/RAC/WAJA) from
|
||||
// the same `state` value — e.g. a US "CA" would seed RAC@CA too.
|
||||
try {
|
||||
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
||||
const seed = all
|
||||
.filter((r: any) => r.pickable)
|
||||
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||
.join(';');
|
||||
seedAwardRefsRef.current = seed;
|
||||
setAwardRefs(seed);
|
||||
} catch { /* leave manual refs empty on failure */ }
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Recompute the read-only computed refs whenever a source field changes.
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(async () => {
|
||||
try {
|
||||
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
||||
setComputedRefs(all.filter((r: any) => !r.pickable).map((r: any) => ({ code: r.code, ref: r.ref, name: r.name })));
|
||||
} catch { setComputedRefs([]); }
|
||||
}, 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
||||
|
||||
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
|
||||
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
|
||||
// accepts letters (sections/zones), not just numbers.
|
||||
function setExchange(which: 'srx' | 'stx', raw: string) {
|
||||
const t = raw.trim();
|
||||
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
|
||||
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
|
||||
}
|
||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
// Country drives the DXCC entity number (ADIF). The DXCC field is read-only;
|
||||
// picking a Country resolves and stamps its DXCC# so they can't diverge.
|
||||
async function onCountryChange(v: string) {
|
||||
set('country', v);
|
||||
try {
|
||||
const n = await DXCCForCountry(v);
|
||||
set('dxcc', (n && n > 0 ? n : undefined) as any);
|
||||
} catch { /* leave DXCC as-is if resolution fails */ }
|
||||
}
|
||||
|
||||
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
||||
// the draft — handy after correcting the callsign. Only overwrites the
|
||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||
async function fetchLookup() {
|
||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
const r: any = await LookupCallsign(call);
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: r.name ?? d.name,
|
||||
qth: r.qth ?? d.qth,
|
||||
address: r.address ?? (d as any).address,
|
||||
email: r.email ?? (d as any).email,
|
||||
country: r.country ?? d.country,
|
||||
grid: r.grid ?? d.grid,
|
||||
state: r.state ?? d.state,
|
||||
cnty: r.cnty ?? d.cnty,
|
||||
cont: r.cont ?? d.cont,
|
||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
||||
dxcc: r.dxcc || d.dxcc,
|
||||
cqz: r.cqz || d.cqz,
|
||||
ituz: r.ituz || d.ituz,
|
||||
lat: r.lat || d.lat,
|
||||
lon: r.lon || d.lon,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setLocalErr(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
|
||||
} finally {
|
||||
setLooking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
||||
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||
setSaving(true);
|
||||
setLocalErr('');
|
||||
const out: any = {
|
||||
@@ -122,16 +311,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
operator: (draft.operator ?? '').trim().toUpperCase(),
|
||||
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
|
||||
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
|
||||
iota: (draft.iota ?? '').trim().toUpperCase(),
|
||||
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
|
||||
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
|
||||
// iota / sota_ref / pota_ref are set below from the Award Refs tab.
|
||||
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
|
||||
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
||||
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
||||
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
|
||||
qso_date_off: parseLocalISO(dateOff) ?? undefined,
|
||||
freq_hz: freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined,
|
||||
freq_rx_hz: freqRxMhz.trim() ? Math.round(parseFloat(freqRxMhz) * 1_000_000) : undefined,
|
||||
qso_date_off: endEnabled ? (parseLocalISO(dateOff) ?? undefined) : undefined,
|
||||
freq_hz: freqKHz.trim() ? parseInt(freqKHz, 10) * 1000 + (parseInt(freqHz, 10) || 0) : undefined,
|
||||
freq_rx_hz: freqRxKHz.trim() ? parseInt(freqRxKHz, 10) * 1000 + (parseInt(freqRxHz, 10) || 0) : undefined,
|
||||
dxcc: intOrUndef(draft.dxcc),
|
||||
cqz: intOrUndef(draft.cqz),
|
||||
ituz: intOrUndef(draft.ituz),
|
||||
@@ -145,8 +332,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
|
||||
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
|
||||
tx_pwr: numOrUndef(draft.tx_pwr),
|
||||
extras: parseExtras(extrasText),
|
||||
distance: numOrUndef(draft.distance),
|
||||
rx_pwr: numOrUndef(draft.rx_pwr),
|
||||
a_index: numOrUndef(draft.a_index),
|
||||
k_index: numOrUndef(draft.k_index),
|
||||
sfi: numOrUndef(draft.sfi),
|
||||
extras: draft.extras && Object.keys(draft.extras).length ? draft.extras : undefined,
|
||||
};
|
||||
// The Award Refs tab is authoritative for the reference-list awards. Reset
|
||||
// the dedicated columns, then route the picked refs back onto the payload
|
||||
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
|
||||
onSave(out);
|
||||
}
|
||||
|
||||
@@ -166,24 +363,25 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="flex-row items-baseline gap-2">
|
||||
<DialogTitle>Edit QSO</DialogTitle>
|
||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t('qedit.editFieldsFor', { id: draft.id })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="basic" className="flex flex-col overflow-hidden min-h-0">
|
||||
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||
<TabsList className="px-3 overflow-x-auto">
|
||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||
<TabsTrigger value="contacted">Contacted</TabsTrigger>
|
||||
<TabsTrigger value="qsl">QSL</TabsTrigger>
|
||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||
<TabsTrigger value="mystation">My station</TabsTrigger>
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="qsoinfo">{t('qedit.tabQsoInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
|
||||
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
|
||||
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
|
||||
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
|
||||
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
|
||||
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
|
||||
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
|
||||
<TabsTrigger value="extras">
|
||||
Extras
|
||||
{t('qedit.tabAdifFields')}
|
||||
{extrasCount > 0 && (
|
||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||
)}
|
||||
@@ -197,177 +395,356 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto px-5 py-4 flex-1">
|
||||
<TabsContent value="basic" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Callsign" span={6}>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11"
|
||||
<TabsContent value="qsoinfo" className="mt-0">
|
||||
{/* Top: Callsign + RST + Fetch */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label>{t('qedit.callsign')}</Label>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</F>
|
||||
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
|
||||
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
|
||||
<F label="Band">
|
||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</F>
|
||||
<F label="Mode">
|
||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</F>
|
||||
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F>
|
||||
<F label="Band RX">
|
||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">—</SelectItem>
|
||||
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</F>
|
||||
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F>
|
||||
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F>
|
||||
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
|
||||
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
|
||||
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
</div>
|
||||
<div className="flex flex-col w-20"><Label>S</Label>
|
||||
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
||||
<div className="flex flex-col w-20"><Label>R</Label>
|
||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* ── Left column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.rxBand')}</Label>
|
||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.mode')}</Label>
|
||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.country')}</Label>
|
||||
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.country')}
|
||||
onChange={onCountryChange} className="flex-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">ITU</Label>
|
||||
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Label>CQ</Label>
|
||||
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title={t('qedit.dxccTitle')} />
|
||||
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>{t('qedit.qsoStart')}</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||
<div>
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||
const on = !!c;
|
||||
setEndEnabled(on);
|
||||
// Prefill an empty end with the start time so the user
|
||||
// only tweaks the minutes instead of typing a full date.
|
||||
if (on && !dateOff) setDateOff(dateOn);
|
||||
}} /> {t('qedit.qsoEnd')}
|
||||
</Label>
|
||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contacted" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
||||
<F label="QTH" span={3}><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></F>
|
||||
<F label="Address" span={6}><Input value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></F>
|
||||
<F label="Email" span={3}><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></F>
|
||||
<F label="Web" span={3}><Input value={draft.web ?? ''} onChange={(e) => set('web', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
||||
<F label="DXCC"><Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Cont"><Input value={draft.cont ?? ''} onChange={(e) => set('cont', e.target.value)} /></F>
|
||||
<F label="CQ zone"><Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="ITU zone"><Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="State"><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></F>
|
||||
<F label="County"><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></F>
|
||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
||||
<F label="Grid ext"><Input value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} /></F>
|
||||
<F label="VUCC grids" span={2}><Input value={draft.vucc_grids ?? ''} onChange={(e) => set('vucc_grids', e.target.value)} /></F>
|
||||
<F label="IOTA"><Input value={draft.iota ?? ''} onChange={(e) => set('iota', e.target.value)} /></F>
|
||||
<F label="SOTA ref"><Input value={draft.sota_ref ?? ''} onChange={(e) => set('sota_ref', e.target.value)} /></F>
|
||||
<F label="POTA ref"><Input value={draft.pota_ref ?? ''} onChange={(e) => set('pota_ref', e.target.value)} /></F>
|
||||
<F label="Age"><Input type="number" value={draft.age ?? ''} onChange={(e) => set('age', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Latitude"><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Longitude"><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Rig (contacted)" span={3}><Input value={draft.rig ?? ''} onChange={(e) => set('rig', e.target.value)} /></F>
|
||||
<F label="Antenna (contacted)" span={3}><Input value={draft.ant ?? ''} onChange={(e) => set('ant', e.target.value)} /></F>
|
||||
<TabsContent value="contact" className="mt-0">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* Left column */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>{t('qedit.county')}</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||
<div>
|
||||
<Label>{t('qedit.continent')}</Label>
|
||||
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">—</SelectItem>
|
||||
{['NA', 'SA', 'EU', 'AF', 'AS', 'OC', 'AN'].map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
</div>
|
||||
{/* Right column */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>{t('qedit.email')}</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="mt-0">
|
||||
<div className="grid grid-cols-[1fr_240px] gap-5">
|
||||
{/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
|
||||
<div>
|
||||
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} fieldValues={{ state: draft.state ?? '', cnty: draft.cnty ?? '' }} />
|
||||
</div>
|
||||
|
||||
{/* Right: computed awards (read-only) derived from this QSO */}
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<span className="text-xs font-semibold">{t('qedit.computedAuto')}</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t('qedit.computedHint')}
|
||||
</p>
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
||||
{computedRefs.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('qedit.noneYet')}</div>
|
||||
) : (
|
||||
computedRefs.map((r) => (
|
||||
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
|
||||
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="qsl" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="QSL sent"><QslSelect value={draft.qsl_sent ?? ''} onChange={(v) => set('qsl_sent', v)} /></F>
|
||||
<F label="QSL rcvd"><QslSelect value={draft.qsl_rcvd ?? ''} onChange={(v) => set('qsl_rcvd', v)} /></F>
|
||||
<F label="QSL sent date"><Input value={draft.qsl_sent_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_sent_date', e.target.value)} /></F>
|
||||
<F label="QSL rcvd date"><Input value={draft.qsl_rcvd_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_rcvd_date', e.target.value)} /></F>
|
||||
<F label="QSL via" span={3}><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></F>
|
||||
<F label="QSL message" span={3}><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></F>
|
||||
<F label="QSL message rcvd" span={6}><Input value={draft.qslmsg_rcvd ?? ''} onChange={(e) => set('qslmsg_rcvd', e.target.value)} /></F>
|
||||
<F label="LoTW sent"><QslSelect value={draft.lotw_sent ?? ''} onChange={(v) => set('lotw_sent', v)} /></F>
|
||||
<F label="LoTW rcvd"><QslSelect value={draft.lotw_rcvd ?? ''} onChange={(v) => set('lotw_rcvd', v)} /></F>
|
||||
<F label="LoTW sent date"><Input value={draft.lotw_sent_date ?? ''} onChange={(e) => set('lotw_sent_date', e.target.value)} /></F>
|
||||
<F label="LoTW rcvd date"><Input value={draft.lotw_rcvd_date ?? ''} onChange={(e) => set('lotw_rcvd_date', e.target.value)} /></F>
|
||||
<F label="eQSL sent"><QslSelect value={draft.eqsl_sent ?? ''} onChange={(v) => set('eqsl_sent', v)} /></F>
|
||||
<F label="eQSL rcvd"><QslSelect value={draft.eqsl_rcvd ?? ''} onChange={(v) => set('eqsl_rcvd', v)} /></F>
|
||||
<F label="eQSL sent date"><Input value={draft.eqsl_sent_date ?? ''} onChange={(e) => set('eqsl_sent_date', e.target.value)} /></F>
|
||||
<F label="eQSL rcvd date"><Input value={draft.eqsl_rcvd_date ?? ''} onChange={(e) => set('eqsl_rcvd_date', e.target.value)} /></F>
|
||||
<F label="Clublog status" span={2}><Input value={draft.clublog_qso_upload_status ?? ''} onChange={(e) => set('clublog_qso_upload_status', e.target.value)} /></F>
|
||||
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
|
||||
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
|
||||
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
|
||||
<F label="QRZ.com status" span={2}><Input value={draft.qrzcom_qso_upload_status ?? ''} onChange={(e) => set('qrzcom_qso_upload_status', e.target.value)} /></F>
|
||||
<F label="QRZ.com date"><Input value={draft.qrzcom_qso_upload_date ?? ''} onChange={(e) => set('qrzcom_qso_upload_date', e.target.value)} /></F>
|
||||
</div>
|
||||
{(() => {
|
||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
<div className="flex-1 max-w-sm space-y-3">
|
||||
<div>
|
||||
<Label>{t('qedit.manageConf')}</Label>
|
||||
<Select value={confSel} onValueChange={setConfSel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label>
|
||||
{def.rcvd
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
</div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
{def.via && (
|
||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right: live status grid for every channel.
|
||||
Sized by its content, not pinned to a width: a fixed 288px box
|
||||
left the label column too narrow, so "QSL (paper)" wrapped onto
|
||||
two lines and padded out the whole row. There is spare width to
|
||||
the right — spend it on the label. */}
|
||||
<div className="shrink-0">
|
||||
<table className="border-separate" style={{ borderSpacing: 4 }}>
|
||||
<thead>
|
||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||
<th className="font-semibold">{t('qedit.sent')}</th>
|
||||
<th className="font-semibold">{t('qedit.received')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CONFIRMATIONS.map((c) => (
|
||||
<tr key={c.key} className="text-xs">
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contest" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
|
||||
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
|
||||
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||
<F label={t('qedit.contestId')} span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder={t('qedit.rcvdExchange')} onChange={(e) => setExchange('srx', e.target.value)} /></F>
|
||||
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder={t('qedit.sentExchange')} onChange={(e) => setExchange('stx', e.target.value)} /></F>
|
||||
<F label={t('qedit.check')}><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||
<F label={t('qedit.precedence')}><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||
<F label={t('qedit.arrlSection')}><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sat" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Propagation mode">
|
||||
<F label={t('qedit.propMode')}>
|
||||
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</F>
|
||||
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||
<F label={t('qedit.satName')}><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||
<F label={t('qedit.satMode')}><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||
<F label={t('qedit.antAz')}><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.antEl')}><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.antPath')}><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mystation" className="mt-0 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('qedit.myStationHint')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Station callsign" span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Input value={draft.my_country ?? ''} onChange={(e) => set('my_country', e.target.value)} /></F>
|
||||
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
||||
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.cqZone')}><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.ituZone')}><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
|
||||
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||
<F label={t('qedit.sotaRef')}><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||
<F label={t('qedit.potaRef')}><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Comment" span={6}><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></F>
|
||||
<F label="Notes" span={6}><Textarea rows={6} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></F>
|
||||
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.specialActivity')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
|
||||
<F label={t('qedit.sigInfo')} span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||
<F label={t('qedit.wwffRef')} span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.region')}><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power & propagation */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.kIndex')}><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity & clubs */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.identityClubs')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label={t('qedit.contactedOp')} span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.formerCall')} span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.class')}><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
|
||||
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
|
||||
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
||||
<F label="DARC DOK"><Input value={draft.darc_dok ?? ''} onChange={(e) => set('darc_dok', e.target.value)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags & credits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.flagsCredits')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label={t('qedit.qsoComplete')}><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||
<F label={t('qedit.qsoRandom')}><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||
<F label={t('qedit.silentKey')}><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
|
||||
<F label={t('qedit.creditGranted')} span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||
<F label={t('qedit.creditSubmitted')} span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My station extras */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.myStationAdif')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label={t('qedit.myName')} span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||
<F label={t('qedit.myWwffRef')} span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.myArrlSect')} span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||
<F label={t('qedit.mySig')}><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||
<F label={t('qedit.mySigInfo')} span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||
<F label={t('qedit.myDarcDok')}><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||
<F label={t('qedit.myVuccGrids')} span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="extras" className="mt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ADIF fields not promoted to first-class columns. One per line:{' '}
|
||||
<code className="bg-muted px-1 py-0.5 rounded font-mono">FIELD_NAME = value</code>
|
||||
</p>
|
||||
<Textarea rows={14} className="font-mono text-xs" value={extrasText} onChange={(e) => setExtrasText(e.target.value)} />
|
||||
<TabsContent value="extras" className="mt-0">
|
||||
<AdifExtrasEditor value={draft.extras} onChange={(next) => set('extras', next as any)} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="!flex-row gap-2">
|
||||
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
<Trash2 className="size-3.5" /> {t('qedit.delete')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3 } from 'lucide-react';
|
||||
import { Columns3, FilterX } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
// virtual-scroll — everything we want out of the box for a logbook table.
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Custom Quartz theme tuned to match OpsLog's warm palette.
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 32,
|
||||
headerHeight: 34,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
// Shared theme (follows the app palette); this table runs slightly taller rows.
|
||||
const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34 });
|
||||
|
||||
type Props = {
|
||||
rows: QSOForm[];
|
||||
total: number;
|
||||
// Bump this number to programmatically select every row (e.g. after a search
|
||||
// in the QSL Manager, where the default is "all selected").
|
||||
selectAllSignal?: number;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (id: number | null) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award; the cell shows the reference this QSO counts
|
||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||
awardCols?: { code: string; name: string }[];
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||
@@ -65,8 +67,12 @@ function fmtDateUTC(s: any): string {
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const t = String(s).trim();
|
||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
@@ -74,137 +80,251 @@ function fmtDateOnly(s: any): string {
|
||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||
// = shown out of the box; anything else stays hidden until the user toggles
|
||||
// it in the Columns dialog.
|
||||
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
||||
export type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
// Shared so the Worked-before grid (which now also shows full QSO records)
|
||||
// can offer the exact same column choices without duplicating the catalog.
|
||||
// A factory taking the i18n `t()` so headerName/label strings are localized;
|
||||
// hooks can't run at module level, so the component calls this with its own t.
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
// ── QSO basics ──
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.band'), colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.band_rx'), colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.mode'), colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.submode'), colId: 'submode', headerName: t('rqg.c.submode'), field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.freq_hz'), colId: 'freq_hz', headerName: t('rqg.h.freq_hz'), field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.freq_rx_hz'), colId: 'freq_rx_hz', headerName: t('rqg.h.freq_rx_hz'), field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: t('rqg.c.rst_sent'), colId: 'rst_sent', headerName: t('rqg.c.rst_sent'), field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.rst_rcvd'), colId: 'rst_rcvd', headerName: t('rqg.c.rst_rcvd'), field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.tx_pwr'), colId: 'tx_pwr', headerName: t('rqg.c.tx_pwr'), field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
|
||||
// ── Contacted station ──
|
||||
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
|
||||
{ group: 'Contacted', label: t('rqg.c.name'), colId: 'name', headerName: t('rqg.c.name'), field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.qth'), colId: 'qth', headerName: t('rqg.c.qth'), field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.address'), colId: 'address', headerName: t('rqg.c.address'), field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: t('rqg.c.country'), colId: 'country', headerName: t('rqg.c.country'), field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.state'), colId: 'state', headerName: t('rqg.c.state'), field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: t('rqg.c.cnty'), colId: 'cnty', headerName: t('rqg.c.cnty'), field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: t('rqg.c.cont'),colId: 'cont', headerName: t('rqg.h.cont'), field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: t('rqg.c.grid'), colId: 'grid', headerName: t('rqg.c.grid'), field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.gridsquare_ext'), colId: 'gridsquare_ext', headerName: t('rqg.h.gridsquare_ext'), field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.vucc_grids'),colId: 'vucc_grids', headerName: t('rqg.h.vucc_grids'), field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.dxcc'), colId: 'dxcc', headerName: t('rqg.c.dxcc'), field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.cqz'), colId: 'cqz', headerName: t('rqg.c.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.ituz'), colId: 'ituz', headerName: t('rqg.c.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.iota'), colId: 'iota', headerName: t('rqg.c.iota'), field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.sota_ref'), colId: 'sota_ref', headerName: t('rqg.h.sota_ref'), field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.pota_ref'), colId: 'pota_ref', headerName: t('rqg.h.pota_ref'), field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
// ── Uploads ──
|
||||
{ group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
|
||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: t('rqg.c.srx'), colId: 'srx', headerName: t('rqg.c.srx'), field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: t('rqg.c.stx'), colId: 'stx', headerName: t('rqg.c.stx'), field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: t('rqg.c.srx_string'), colId: 'srx_string', headerName: t('rqg.h.srx_string'), field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: t('rqg.c.stx_string'), colId: 'stx_string', headerName: t('rqg.h.stx_string'), field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: t('rqg.c.check'), colId: 'check', headerName: t('rqg.c.check'), field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: t('rqg.c.precedence'), colId: 'precedence', headerName: t('rqg.c.precedence'), field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: t('rqg.c.arrl_sect'),colId: 'arrl_sect', headerName: t('rqg.h.arrl_sect'), field: 'arrl_sect' as any, width: 90 },
|
||||
|
||||
// ── Propagation / antenna ──
|
||||
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
||||
{ group: 'Propagation', label: t('rqg.c.prop_mode'), colId: 'prop_mode', headerName: t('rqg.h.prop_mode'), field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: t('rqg.c.sat_name'), colId: 'sat_name', headerName: t('rqg.h.sat_name'), field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: t('rqg.c.sat_mode'), colId: 'sat_mode', headerName: t('rqg.c.sat_mode'), field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_az'), colId: 'ant_az', headerName: t('rqg.h.ant_az'), field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_el'), colId: 'ant_el', headerName: t('rqg.h.ant_el'), field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_path'), colId: 'ant_path', headerName: t('rqg.h.ant_path'), field: 'ant_path' as any, width: 70 },
|
||||
|
||||
// ── My station (operator side) ──
|
||||
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.station_callsign'), colId: 'station_callsign', headerName: t('rqg.h.station_callsign'), field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: t('rqg.c.operator'), colId: 'operator', headerName: t('rqg.c.operator'),field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_grid'), colId: 'my_grid', headerName: t('rqg.c.my_grid'), field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_country'), colId: 'my_country', headerName: t('rqg.h.my_country'), field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_state'), colId: 'my_state', headerName: t('rqg.c.my_state'),field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: t('rqg.c.my_cnty'), colId: 'my_cnty', headerName: t('rqg.h.my_cnty'), field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: t('rqg.c.my_iota'), colId: 'my_iota', headerName: t('rqg.c.my_iota'), field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_sota'), colId: 'my_sota_ref', headerName: t('rqg.c.my_sota'), field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_pota'), colId: 'my_pota_ref', headerName: t('rqg.c.my_pota'), field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_dxcc'), colId: 'my_dxcc', headerName: t('rqg.h.my_dxcc'),field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_cq_zone'), colId: 'my_cq_zone', headerName: t('rqg.h.my_cq_zone'), field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_itu_zone'), colId: 'my_itu_zone', headerName: t('rqg.h.my_itu_zone'), field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_lat'), colId: 'my_lat', headerName: t('rqg.c.my_lat'), field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_lon'), colId: 'my_lon', headerName: t('rqg.c.my_lon'), field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_street'), colId: 'my_street', headerName: t('rqg.h.my_street'), field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: t('rqg.c.my_city'), colId: 'my_city', headerName: t('rqg.h.my_city'), field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
|
||||
// ── Misc ──
|
||||
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: t('rqg.c.notes'), colId: 'notes', headerName: t('rqg.c.notes'), field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: t('rqg.c.created'), colId: 'created_at', headerName: t('rqg.h.created'), field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: t('rqg.c.updated'), colId: 'updated_at', headerName: t('rqg.h.updated'), field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
];
|
||||
|
||||
const GROUP_ORDER = [
|
||||
export const GROUP_ORDER = [
|
||||
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) {
|
||||
// Localized display label for a column group (identifier stays English so it
|
||||
// keeps working as a filter key).
|
||||
const GRP_KEYS: Record<string, string> = {
|
||||
QSO: 'rqg.grpQso', Contacted: 'rqg.grpContacted', QSL: 'rqg.grpQsl',
|
||||
LoTW: 'rqg.grpLotw', eQSL: 'rqg.grpEqsl', Uploads: 'rqg.grpUploads',
|
||||
Contest: 'rqg.grpContest', Propagation: 'rqg.grpProp',
|
||||
'My station': 'rqg.grpMyStation', Misc: 'rqg.grpMisc',
|
||||
};
|
||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
// Right-click: if the clicked row isn't already part of the selection,
|
||||
// select just it; then open the bulk-action menu on the whole selection.
|
||||
function onCellContextMenu(e: any) {
|
||||
const ev = e.event as MouseEvent | undefined;
|
||||
ev?.preventDefault();
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
if (e.node && !e.node.isSelected()) {
|
||||
api.deselectAll();
|
||||
e.node.setSelected(true);
|
||||
}
|
||||
const ids = (api.getSelectedRows() as QSOForm[])
|
||||
.map((r) => r.id as number)
|
||||
.filter((n) => !!n);
|
||||
if (ids.length === 0) return;
|
||||
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
|
||||
}
|
||||
|
||||
// Compute initial column defs: all columns defined, but those not marked
|
||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||
// overrides this so a previously toggled column wins.
|
||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
// While AG Grid rebuilds columns (award columns load async), it fires column
|
||||
// events that would otherwise trigger a save of the DEFAULT visibility before
|
||||
// we re-apply the user's saved state — clobbering it. This flag suppresses the
|
||||
// auto-save during that window. Set in the memo (runs at render, before the
|
||||
// column events) and cleared by the re-apply effect below.
|
||||
const restoringRef = useRef(true);
|
||||
|
||||
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown';
|
||||
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||
useEffect(() => {
|
||||
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||
if (remote && remote.length) {
|
||||
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||
setAwardShown(next);
|
||||
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||
}, []);
|
||||
|
||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
const base = COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
});
|
||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
cellClass: 'text-[11px]',
|
||||
// Visibility comes from the persisted award-code set, so a column the user
|
||||
// showed reappears on reopen and one they didn't stays hidden.
|
||||
hide: !awardShown.has(a.code.toUpperCase()),
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols, COL_CATALOG, t, awardShown]);
|
||||
|
||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||
// so we set it explicitly whenever the award set or the loaded columns change.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api || !awardCols?.length) return;
|
||||
for (const a of awardCols) {
|
||||
const want = awardShown.has(a.code.toUpperCase());
|
||||
const col = api.getColumn(`award_${a.code}`);
|
||||
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||
}
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
@@ -214,40 +334,71 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) {
|
||||
e.api.applyColumnState({ state, applyOrder: true });
|
||||
}
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
// Fall back to the portable DB copy when the local cache is empty
|
||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
|
||||
// The award columns load asynchronously; when they arrive (or change) the
|
||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
||||
// default — wiping the user's saved visibility (award columns reappear,
|
||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||
}
|
||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||
useEffect(() => {
|
||||
if (selectAllSignal === undefined) return;
|
||||
gridRef.current?.api?.selectAll();
|
||||
}, [selectAllSignal]);
|
||||
|
||||
// ── Column picker (visibility) ──
|
||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||
// state for "which columns are visible" — AG Grid's column state is the
|
||||
// source of truth, and saveColumnState persists it.
|
||||
function isColVisible(colId: string): boolean {
|
||||
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
}
|
||||
function setColVisible(colId: string, visible: boolean) {
|
||||
// Award columns are driven by the persisted code set — update it and let the
|
||||
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||
if (colId.startsWith('award_')) {
|
||||
const code = colId.slice('award_'.length).toUpperCase();
|
||||
const next = new Set(awardShown);
|
||||
if (visible) next.add(code); else next.delete(code);
|
||||
persistAwardShown(next);
|
||||
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||
return;
|
||||
}
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.setColumnsVisible([colId], visible);
|
||||
@@ -275,13 +426,20 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
||||
api.setColumnsVisible(visible, true);
|
||||
api.setColumnsVisible(hidden, false);
|
||||
saveColumnState();
|
||||
// Award columns are opt-in: reset hides them all.
|
||||
persistAwardShown(new Set());
|
||||
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title={t('rqg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('rqg.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
@@ -292,7 +450,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onGridReady={onGridReady}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
@@ -301,6 +459,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onSelectionChanged={onSelectionChanged}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
preventDefaultOnContextMenu
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
@@ -308,26 +468,42 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QSOContextMenu
|
||||
menu={menu}
|
||||
onClose={() => setMenu(null)}
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onUpdateFromClublog={onUpdateFromClublog}
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportFiltered={onExportFiltered}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Columns</DialogTitle>
|
||||
<DialogTitle>{t('rqg.columns')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Recent QSOs table.
|
||||
Your selection is saved.
|
||||
{t('rqg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto py-2">
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
if (cols.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -344,10 +520,30 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{awardCols && awardCols.length > 0 && (
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{awardCols.map((a) => (
|
||||
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
||||
<span className="font-mono font-semibold">{a.code}</span>
|
||||
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('rqg.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('rqg.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// RotorCompass — an azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
|
||||
//
|
||||
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
|
||||
// a green azimuth bezel. A green needle shows the antenna heading (two needles
|
||||
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||
// to turn the antenna there.
|
||||
import { useMemo } from 'react';
|
||||
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||
import { feature } from 'topojson-client';
|
||||
import landTopo from 'world-atlas/land-110m.json';
|
||||
import { Compass, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||
const GRATICULE = geoGraticule10();
|
||||
|
||||
interface Props {
|
||||
bearing?: number | null; // short-path azimuth to DX (deg)
|
||||
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
|
||||
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
|
||||
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
|
||||
centerLat?: number | null; // operator latitude (projection centre)
|
||||
centerLon?: number | null; // operator longitude
|
||||
rotorEnabled?: boolean;
|
||||
onGoto?: (az: number) => void; // click-to-turn
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SIZE = 168;
|
||||
const C = SIZE / 2;
|
||||
const R = C - 6; // outer bezel radius
|
||||
const MAP_R = R - 6; // map/clip radius (inside the bezel)
|
||||
|
||||
function pt(az: number, radius: number): [number, number] {
|
||||
const a = ((az - 90) * Math.PI) / 180;
|
||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||
}
|
||||
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
||||
const cardinals = useMemo(
|
||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||
[],
|
||||
);
|
||||
|
||||
// Project the world centred on the QTH (north up; antipode at the bezel).
|
||||
const { land, grat } = useMemo(() => {
|
||||
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
|
||||
const proj = geoAzimuthalEquidistant()
|
||||
.rotate([-centerLon, -centerLat])
|
||||
.clipAngle(179.9)
|
||||
.scale(MAP_R / Math.PI)
|
||||
.translate([C, C]);
|
||||
const path = geoPath(proj as any);
|
||||
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
|
||||
}, [centerLat, centerLon]);
|
||||
|
||||
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||
if (!onGoto) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
|
||||
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
|
||||
az = ((az % 360) + 360) % 360;
|
||||
onGoto(Math.round(az));
|
||||
}
|
||||
|
||||
const headLabel = headings.length ? headings[0] : null;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||
<Compass className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||
<div className="flex-1" />
|
||||
{pattern && (
|
||||
<span
|
||||
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
|
||||
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
|
||||
: 'bg-success-muted text-success-muted-foreground')}
|
||||
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||
: 'Ultrabeam normal'}>
|
||||
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-sm font-bold text-success tabular-nums">
|
||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||
</span>
|
||||
{onClose && (
|
||||
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-2 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||
style={{ width: SIZE, height: SIZE }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
|
||||
</defs>
|
||||
{/* water + world map, clipped to the dial */}
|
||||
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
|
||||
<g clipPath="url(#rotorDial)">
|
||||
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
|
||||
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
|
||||
</g>
|
||||
{/* green azimuth bezel */}
|
||||
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
|
||||
|
||||
{/* ticks every 10°, longer at 30° */}
|
||||
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
|
||||
const major = d % 30 === 0;
|
||||
const [x1, y1] = pt(d, MAP_R);
|
||||
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
|
||||
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
|
||||
})}
|
||||
{/* cardinal labels + degree numbers at 45° */}
|
||||
{cardinals.map(({ d, l }) => {
|
||||
const [x, y] = pt(d, MAP_R - 13);
|
||||
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
|
||||
})}
|
||||
|
||||
{/* DX short-path bearing → small red marker on the bezel */}
|
||||
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
|
||||
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||
); })()}
|
||||
|
||||
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
|
||||
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
|
||||
operator sees where the antenna physically points vs where it boom-sits */}
|
||||
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
|
||||
const [x, y] = pt(boomHeading, MAP_R - 2);
|
||||
return (
|
||||
<g>
|
||||
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
|
||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
|
||||
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* radiating heading needle(s) — green; two when bidirectional */}
|
||||
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||
<g key={i}>
|
||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
|
||||
</g>
|
||||
); })}
|
||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Satellite, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface RecentSpotQSO {
|
||||
callsign: string;
|
||||
freqKHz: number;
|
||||
mode: string;
|
||||
band?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
// Pre-fill values: callsign from the QSO entry (or last logged), the
|
||||
// current TX freq in kHz, and the current mode (goes into the comment).
|
||||
defaultCall: string;
|
||||
defaultFreqKHz: number;
|
||||
defaultMode: string;
|
||||
// Master cluster name, shown so the user knows where the spot goes.
|
||||
targetName?: string;
|
||||
recent: RecentSpotQSO[];
|
||||
onSend: (call: string, freqKHz: number, comment: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on
|
||||
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||
// contact into the form.
|
||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [call, setCall] = useState('');
|
||||
const [freqKHz, setFreqKHz] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [ok, setOk] = useState(false);
|
||||
const callRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// (Re)initialise the form each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCall((defaultCall || '').toUpperCase());
|
||||
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
||||
setMessage(defaultMode || '');
|
||||
setError('');
|
||||
setOk(false);
|
||||
// Focus the freq if the call is already known, else the call.
|
||||
setTimeout(() => callRef.current?.focus(), 50);
|
||||
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
|
||||
|
||||
async function send() {
|
||||
const c = call.trim().toUpperCase();
|
||||
const f = parseFloat(freqKHz);
|
||||
if (!c) { setError(t('spm.callRequired')); return; }
|
||||
if (!f || f <= 0) { setError(t('spm.freqRequired')); return; }
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSend(c, f, message.trim());
|
||||
setOk(true);
|
||||
// Brief success flash, then close.
|
||||
setTimeout(() => { setOk(false); onClose(); }, 700);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(q: RecentSpotQSO) {
|
||||
setCall(q.callsign.toUpperCase());
|
||||
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
||||
if (q.mode) setMessage(q.mode);
|
||||
setError('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Satellite className="size-4 text-primary" /> {t('spm.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col flex-1">
|
||||
<Label className="mb-1">{t('spm.callsign')}</Label>
|
||||
<Input
|
||||
ref={callRef}
|
||||
className="font-mono uppercase font-bold"
|
||||
value={call}
|
||||
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder={t('spm.callPh')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1">{t('spm.frequency')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={freqKHz}
|
||||
onChange={(e) => setFreqKHz(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="14205"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">{t('spm.message')}</Label>
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder={t('spm.messagePh')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1 block">{t('spm.latestQsos')}</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
|
||||
{recent.map((q, i) => (
|
||||
<button
|
||||
key={`${q.callsign}-${i}`}
|
||||
type="button"
|
||||
onClick={() => pick(q)}
|
||||
className="flex w-full items-center gap-2 px-2 py-1 text-left text-xs hover:bg-accent/40"
|
||||
>
|
||||
<span className="font-mono font-bold w-24 truncate">{q.callsign}</span>
|
||||
<span className="font-mono text-muted-foreground w-20 text-right">{q.freqKHz > 0 ? trimKHz(q.freqKHz) : '—'}</span>
|
||||
<span className="text-muted-foreground">{q.mode || ''}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
||||
{ok ? t('spm.spotSent') : targetName ? `→ ${targetName}` : t('spm.masterCluster')}
|
||||
</span>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('spm.cancel')}</Button>
|
||||
<Button onClick={send} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
||||
{busy ? t('spm.sending') : t('spm.sendSpot')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// trimKHz formats a kHz value without a trailing ".0" (14205) but keeps
|
||||
// sub-kHz precision when present (10138.7).
|
||||
function trimKHz(khz: number): string {
|
||||
return String(Math.round(khz * 10) / 10).replace(/\.0$/, '');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,15 +37,15 @@ export function ShutdownProgress() {
|
||||
) : steps.map((s) => (
|
||||
<div key={s.id} className="flex items-start gap-2 text-sm">
|
||||
<div className="mt-0.5 w-4 flex items-center justify-center">
|
||||
{s.status === 'done' && <CheckCircle2 className="size-4 text-emerald-600" />}
|
||||
{s.status === 'done' && <CheckCircle2 className="size-4 text-success" />}
|
||||
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
|
||||
{s.status === 'error' && <XCircle className="size-4 text-rose-600" />}
|
||||
{s.status === 'error' && <XCircle className="size-4 text-danger" />}
|
||||
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={
|
||||
s.status === 'done' ? 'text-foreground'
|
||||
: s.status === 'error' ? 'text-rose-700 font-medium'
|
||||
: s.status === 'error' ? 'text-danger font-medium'
|
||||
: s.status === 'pending' ? 'text-muted-foreground'
|
||||
: 'text-foreground font-medium'
|
||||
}>
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
|
||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
||||
|
||||
function blankDevice(): Device {
|
||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||
}
|
||||
|
||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||
|
||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||||
// passed in (so the panel can also order this widget); it drives the shared
|
||||
// rotator backend.
|
||||
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||||
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const [goto, setGoto] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const turn = (az: number) => {
|
||||
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
};
|
||||
const poll = refetch;
|
||||
|
||||
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Compass className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
<RotorCompass
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
rotorEnabled={hd.ok}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="font-mono">
|
||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{presets.map(([lbl, az]) => (
|
||||
<button key={lbl} type="button" onClick={() => turn(az)}
|
||||
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||||
placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
|
||||
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||||
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||||
</Button>
|
||||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||
const { t } = useI18n();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||||
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||
const [order, setOrder] = useState<string[]>(() => {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||
});
|
||||
const dragId = useRef<string | null>(null);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||
}, []);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
|
||||
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||
} catch { /* ignore transient */ }
|
||||
}, []);
|
||||
const pollRot = useCallback(async () => {
|
||||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||
useEffect(() => {
|
||||
poll(); pollRot();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); }, 3000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [poll, pollRot, devices.length]);
|
||||
|
||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||
// Reorder so `dragged` lands just before `target`.
|
||||
const onDrop = (targetId: string) => {
|
||||
const src = dragId.current; dragId.current = null;
|
||||
if (!src || src === targetId) return;
|
||||
const ids = widgetIds.filter((id) => id !== src);
|
||||
const at = ids.indexOf(targetId);
|
||||
ids.splice(at < 0 ? ids.length : at, 0, src);
|
||||
persistOrder(ids);
|
||||
};
|
||||
|
||||
const persist = async (next: Device[]) => {
|
||||
setDevices(next);
|
||||
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
|
||||
await loadDevices();
|
||||
poll();
|
||||
};
|
||||
|
||||
const toggle = async (dev: Device, relay: number, on: boolean) => {
|
||||
const key = `${dev.id}:${relay}`;
|
||||
setBusy((b) => ({ ...b, [key]: true }));
|
||||
// Optimistic flip so the switch feels instant; the poll reconciles.
|
||||
setStatus((st) => {
|
||||
const d = st[dev.id]; if (!d) return st;
|
||||
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
|
||||
});
|
||||
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
|
||||
await poll();
|
||||
setBusy((b) => ({ ...b, [key]: false }));
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editing) return;
|
||||
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
|
||||
const exists = devices.some((x) => x.id && x.id === d.id);
|
||||
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
|
||||
|
||||
// Build the widget list (rotator first by default, then devices), then order it
|
||||
// by the saved drag order — unknown ids fall to the end in their natural order.
|
||||
const deviceCard = (dev: Device) => {
|
||||
const st = status[dev.id];
|
||||
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<PlugZap className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
||||
</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2">
|
||||
{relays.map((r) => {
|
||||
const key = `${dev.id}:${r.number}`;
|
||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||
return (
|
||||
<button key={r.number} type="button" disabled={!st?.connected}
|
||||
onClick={() => toggle(dev, r.number, !r.on)}
|
||||
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
||||
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
||||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium truncate">{label}</span>
|
||||
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
||||
{r.on ? t('station.on') : t('station.off')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||
const widgetIds = ordered.map((w) => w.id);
|
||||
|
||||
const noDevices = devices.length === 0 && !rot.enabled;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3 max-w-4xl">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{noDevices && !editing && (
|
||||
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
{t('station.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start">
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id} draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
||||
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
||||
{w.node}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!noDevices && (
|
||||
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||
const setType = (type: string) => {
|
||||
const n = RELAY_COUNT[type] ?? 5;
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
return (
|
||||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.type')}</Label>
|
||||
<Select value={device.type} onValueChange={setType}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.name')}</Label>
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
</div>
|
||||
{isKM && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.user')}</Label>
|
||||
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.pass')}</Label>
|
||||
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.labels')}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{device.labels.map((lab, i) => (
|
||||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Statistics dashboard.
|
||||
//
|
||||
// Colour rules this file obeys (they are not stylistic — they are what keeps the
|
||||
// charts readable and colour-blind-safe):
|
||||
//
|
||||
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
|
||||
// encodes identity, never a magnitude the bar's length already shows.
|
||||
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
|
||||
// the categories ARE the subject (the continent donut). The order is the
|
||||
// colour-blind-safety mechanism, so it is never shuffled or cycled.
|
||||
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
|
||||
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
|
||||
// makes them interesting. Close values belong in bars.
|
||||
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
|
||||
// a table view gives the WCAG-clean twin.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Bucket = { key: string; count: number };
|
||||
type Gap = { start: string; end: string; minutes: number };
|
||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||
type Stats = {
|
||||
total: number; unique_calls: number; entities: number; continents: number;
|
||||
first_qso: string; last_qso: string;
|
||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
// Period / contest metrics.
|
||||
window_start: string; window_end: string; window_hours: number;
|
||||
avg_per_hour: number; avg_per_active: number;
|
||||
on_air_minutes: number; off_air_minutes: number;
|
||||
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||
gaps: Gap[]; rate: Bucket[];
|
||||
rate_ops: string[]; rate_by_op: number[][];
|
||||
};
|
||||
|
||||
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
|
||||
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
|
||||
|
||||
const CONTINENT_NAME: Record<string, string> = {
|
||||
EU: 'Europe', NA: 'North America', SA: 'South America',
|
||||
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
|
||||
};
|
||||
|
||||
const nf = (n: number) => n.toLocaleString('en-US');
|
||||
|
||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A headline number IS the chart — a one-bar bar chart would be noise.
|
||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
|
||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||
// on a tooltip to get a number.
|
||||
|
||||
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
|
||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||
const top = max ? data.slice(0, max) : data;
|
||||
const peak = Math.max(1, ...top.map((d) => d.count));
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{top.map((d) => (
|
||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||
<div
|
||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||
{share && (
|
||||
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
|
||||
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
|
||||
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||
// information.
|
||||
|
||||
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
||||
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
|
||||
// (the value is in the tooltip, and every number is in the table view).
|
||||
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
|
||||
return (
|
||||
// The container includes the x-axis band — a fixed height that excludes it is
|
||||
// how cards end up with a tiny nested scrollbar.
|
||||
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||
title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||||
{nf(d.count)}
|
||||
</span>
|
||||
<div
|
||||
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
/>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||
{i % every === 0 ? d.key : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||
// keeps the same hue everywhere on the page — a chart that repaints its series
|
||||
// when the filter changes is a chart nobody can trust.
|
||||
|
||||
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
|
||||
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
|
||||
}) {
|
||||
const [hov, setHov] = useState<number | null>(null);
|
||||
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
const peak = Math.max(1, ...rate.map((d) => d.count));
|
||||
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
|
||||
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||
{rate.map((d, i) => (
|
||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
|
||||
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
|
||||
{/* The column is the hour's total; the segments are who made it. */}
|
||||
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
|
||||
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
|
||||
{(byOp[i] ?? []).map((n, o) => n > 0 && (
|
||||
<div key={o} style={{
|
||||
height: `${(n / Math.max(1, d.count)) * 100}%`,
|
||||
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
|
||||
{i % every === 0 ? d.key : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hover read-out: the hour, its total, and the split. Values are also in the
|
||||
rate sheet below, so nothing is reachable by hover alone. */}
|
||||
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
|
||||
{hov !== null && (
|
||||
<>
|
||||
<span className="text-foreground font-medium">{rate[hov].key}</span>
|
||||
{' · '}{nf(rate[hov].count)} QSO
|
||||
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
|
||||
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{!single && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||
{ops.map((op, o) => (
|
||||
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||
{op}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
|
||||
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
|
||||
// read rate sheets as tables, and every value here is also the one the chart draws.
|
||||
|
||||
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
|
||||
if (rate.length === 0) return null;
|
||||
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
|
||||
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
|
||||
const grand = rate.reduce((s, d) => s + d.count, 0);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
|
||||
<table className="w-full text-[11px] tabular-nums">
|
||||
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium px-2 py-1">UTC</th>
|
||||
{ops.map((op, o) => (
|
||||
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
|
||||
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
|
||||
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||
{op}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map(({ d, i }) => (
|
||||
<tr key={d.key} className="border-t border-border/50">
|
||||
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
|
||||
{ops.map((_, o) => (
|
||||
<td key={o} className="px-2 py-0.5 text-right">
|
||||
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
|
||||
<tr className="border-t border-border font-semibold">
|
||||
<td className="px-2 py-1">Total</td>
|
||||
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
|
||||
<td className="px-2 py-1 text-right">{nf(grand)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||
// is chaos and goes unread.
|
||||
|
||||
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
|
||||
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
|
||||
const x = (i: number) => (i / (data.length - 1)) * W;
|
||||
const y = (v: number) => H - (v / peak) * H;
|
||||
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
|
||||
const area = `${line} L${W},${H} L0,${H} Z`;
|
||||
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
|
||||
const h = hover ?? -1;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="relative" style={{ height }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||||
onMouseLeave={() => setHover(null)}>
|
||||
{/* Recessive hairline grid — solid, never dashed. */}
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<defs>
|
||||
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#statsArea)" />
|
||||
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
{h >= 0 && (
|
||||
<>
|
||||
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
</>
|
||||
)}
|
||||
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||
{data.map((_, i) => (
|
||||
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||
))}
|
||||
</svg>
|
||||
{h >= 0 && (
|
||||
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||
<span>{data[0].key}</span>
|
||||
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||
<span>{data[data.length - 1].key}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||
// donut would hide precisely the fact that makes them interesting).
|
||||
//
|
||||
// The categories ARE the subject here, so this is the one chart using the
|
||||
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||
|
||||
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||
const [hov, setHov] = useState<number | null>(null);
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
|
||||
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||
let acc = 0;
|
||||
const arcs = slots.map((d, i) => {
|
||||
const frac = d.count / total;
|
||||
// A 2px surface gap between segments — the correct separator, not a border.
|
||||
const len = Math.max(0, frac * C - 2);
|
||||
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||
acc += frac;
|
||||
return a;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="relative shrink-0">
|
||||
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||
{arcs.map((a) => (
|
||||
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||
className="transition-[stroke-width] duration-150 cursor-default"
|
||||
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||
))}
|
||||
</svg>
|
||||
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
{hov === null ? (
|
||||
<>
|
||||
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
{slots.map((d, i) => (
|
||||
<li key={d.key}
|
||||
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||
hov === i && 'bg-muted')}>
|
||||
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||
{((d.count / total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||
|
||||
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||
<span className="text-[11px] tabular-nums">
|
||||
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||
<div className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Period ───────────────────────────────────────────────────────────────────
|
||||
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||
// the charts would each show a different slice of time.
|
||||
|
||||
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||
const now = new Date();
|
||||
switch (p) {
|
||||
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||
case 'y12': {
|
||||
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'd30': {
|
||||
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'custom': return [from, to];
|
||||
default: return ['', ''];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||
|
||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{data.map((d) => (
|
||||
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function StatsPanel() {
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||
const [period, setPeriod] = useState<Period>('all');
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||
// that contest AND lets the window derive from its own span — no date typing.
|
||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||
const [contest, setContest] = useState('');
|
||||
|
||||
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||
|
||||
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
|
||||
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||
// with it — the backend derives them. Sending a period as well would be two
|
||||
// filters fighting over the same axis.
|
||||
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
||||
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||
// once, here, rather than guarding at every use site and missing one.
|
||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||
setStats({
|
||||
...raw,
|
||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||
} as Stats);
|
||||
}
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||
useEffect(() => {
|
||||
if (!contest && period === 'custom' && !(from && to)) return;
|
||||
load(period, from, to, contest);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [period, from, to, contest]);
|
||||
|
||||
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||
// between two ordinary evenings — numbers that are true and completely useless.
|
||||
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||
// real operating session (which is exactly when an hourly rate chart exists).
|
||||
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||
|
||||
const span = useMemo(() => {
|
||||
if (!stats?.first_qso) return '';
|
||||
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||
}, [stats]);
|
||||
|
||||
if (busy && !stats) {
|
||||
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||
</div>;
|
||||
}
|
||||
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||
if (!stats) return null;
|
||||
|
||||
const empty = t('stats.noData');
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||
|
||||
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||
never from a static list — so it can't offer a contest you never entered.
|
||||
Choosing one supersedes the period: it brings its own window. */}
|
||||
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||
<option value="">{t('stats.noContest')}</option>
|
||||
{runs.map((r) => (
|
||||
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||
{r.id} {r.year} — {nf(r.count)} QSO
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||
{([
|
||||
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||
] as [Period, string][]).map(([p, label], i) => (
|
||||
<button key={p} onClick={() => setPeriod(p)}
|
||||
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!contest && period === 'custom' && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<button onClick={() => setView('charts')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||
</button>
|
||||
<button onClick={() => setView('table')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<Table2 className="size-3.5" />{t('stats.table')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
||||
</div>
|
||||
|
||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||
{windowed && stats.total > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||
<div title={t('stats.avgWindowTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.avgActiveTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.best60Tip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||
</div>
|
||||
{/* On-air + off-air = the window, by construction. The first version
|
||||
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||
<div title={t('stats.onAirTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||
{dur(stats.on_air_minutes)}
|
||||
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.rate.length > 1
|
||||
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||||
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||
{stats.gaps.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 min-w-0">
|
||||
{stats.gaps.map((g, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||
<span className="tabular-nums text-muted-foreground truncate">
|
||||
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||
</span>
|
||||
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||
style={{
|
||||
background: 'var(--chart-seq-1)',
|
||||
}}>
|
||||
<span className="block h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||
background: 'var(--chart-1)',
|
||||
}} />
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
|
||||
by operator. That is a table's job, not a chart's — and it's how
|
||||
contesters actually read a run. Every value here is also what the
|
||||
stacked chart above draws, so the two can never disagree. */}
|
||||
{stats.rate.length > 1 && (
|
||||
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||||
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'table' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||
<VBars data={stats.by_band} empty={empty} />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')}>
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
||||
<AreaTrend data={stats.by_month} empty={empty} />
|
||||
</Card>
|
||||
|
||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||
instead of truncating. */}
|
||||
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_operator} empty={empty} share />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||||
<Donut data={stats.by_continent} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')}>
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1">
|
||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byStation')}>
|
||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_station} empty={empty} share />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Local mirror of the Go struct — we duplicate the type rather than depend
|
||||
// on the generated Wails model because the inline `as any` casts are
|
||||
@@ -23,7 +24,7 @@ type UDPConfig = {
|
||||
direction: 'inbound' | 'outbound';
|
||||
name: string;
|
||||
port: number;
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated';
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo';
|
||||
multicast: boolean;
|
||||
multicast_group: string;
|
||||
destination_ip: string;
|
||||
@@ -44,43 +45,58 @@ const SERVICE_TYPES: Array<{
|
||||
{
|
||||
id: 'wsjt',
|
||||
direction: 'inbound',
|
||||
label: 'WSJT-X / JTDX / MSHV',
|
||||
hint: 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.',
|
||||
label: 'udpp.svcWsjtLabel',
|
||||
hint: 'udpp.svcWsjtHint',
|
||||
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'adif',
|
||||
direction: 'inbound',
|
||||
label: 'ADIF message (JTAlert, GridTracker)',
|
||||
hint: 'Receives a single ADIF record per packet and logs it.',
|
||||
label: 'udpp.svcAdifLabel',
|
||||
hint: 'udpp.svcAdifHint',
|
||||
defaults: { port: 2333, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'n1mm',
|
||||
direction: 'inbound',
|
||||
label: 'N1MM Logger+ (contest XML)',
|
||||
hint: 'Receives contest QSOs as XML messages.',
|
||||
label: 'udpp.svcN1mmLabel',
|
||||
hint: 'udpp.svcN1mmHint',
|
||||
defaults: { port: 12060, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'remote_call',
|
||||
direction: 'inbound',
|
||||
label: 'Remote callsign (DXHunter, custom)',
|
||||
hint: 'A short text packet containing just a callsign — fills the entry field.',
|
||||
label: 'udpp.svcRemoteLabel',
|
||||
hint: 'udpp.svcRemoteHint',
|
||||
defaults: { port: 12090, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'db_updated',
|
||||
direction: 'outbound',
|
||||
label: 'DB updated → notify other apps',
|
||||
hint: 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
|
||||
label: 'udpp.svcDbLabel',
|
||||
hint: 'udpp.svcDbHint',
|
||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'pstrotator_freq',
|
||||
direction: 'outbound',
|
||||
label: 'udpp.svcPstLabel',
|
||||
hint: 'udpp.svcPstHint',
|
||||
defaults: { port: 12040, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'n1mm_radioinfo',
|
||||
direction: 'outbound',
|
||||
label: 'udpp.svcN1mmRadioLabel',
|
||||
hint: 'udpp.svcN1mmRadioHint',
|
||||
defaults: { port: 12060, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
];
|
||||
|
||||
type Props = { onError: (msg: string) => void };
|
||||
|
||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<UDPConfig | null>(null);
|
||||
@@ -123,7 +139,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this UDP connection?')) return;
|
||||
if (!confirm(t('udpp.deleteConfirm'))) return;
|
||||
try {
|
||||
await DeleteUDPIntegration(id);
|
||||
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||
@@ -143,7 +159,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-xs text-muted-foreground italic">Loading…</div>;
|
||||
if (loading) return <div className="text-xs text-muted-foreground italic">{t('udpp.loading')}</div>;
|
||||
|
||||
const inbound = items.filter((i) => i.direction === 'inbound');
|
||||
const outbound = items.filter((i) => i.direction === 'outbound');
|
||||
@@ -151,15 +167,11 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
UDP connections let OpsLog talk to other ham radio software. Inbound
|
||||
connections receive QSOs or callsigns and update the logbook live;
|
||||
outbound connections notify other apps when you log a QSO locally.
|
||||
Enable multicast to share a port with another listener without
|
||||
conflict — required for the typical WSJT-X 2237 setup.
|
||||
{t('udpp.intro')}
|
||||
</div>
|
||||
|
||||
<Section
|
||||
title="Inbound — OpsLog listens"
|
||||
title={t('udpp.inboundTitle')}
|
||||
icon={<ArrowDownToLine className="size-4" />}
|
||||
items={inbound}
|
||||
onAdd={() => addNew('inbound')}
|
||||
@@ -168,7 +180,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
onToggle={toggleEnabled}
|
||||
/>
|
||||
<Section
|
||||
title="Outbound — OpsLog sends"
|
||||
title={t('udpp.outboundTitle')}
|
||||
icon={<ArrowUpFromLine className="size-4" />}
|
||||
items={outbound}
|
||||
onAdd={() => addNew('outbound')}
|
||||
@@ -179,10 +191,10 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||
<Button size="sm" variant="outline" onClick={reloadServers}>
|
||||
<RefreshCcw className="size-3.5" /> Reload all
|
||||
<RefreshCcw className="size-3.5" /> {t('udpp.reloadAll')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Restarts every enabled listener after a manual change.
|
||||
{t('udpp.reloadHint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -210,6 +222,7 @@ function Section({
|
||||
onDelete: (id: number) => void;
|
||||
onToggle: (c: UDPConfig) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
@@ -217,11 +230,11 @@ function Section({
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onAdd}>
|
||||
<Plus className="size-3" /> Add
|
||||
<Plus className="size-3" /> {t('udpp.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground italic">No connection.</div>
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground italic">{t('udpp.noConnection')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{items.map((c) => {
|
||||
@@ -231,9 +244,9 @@ function Section({
|
||||
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm truncate">{c.name || '(unnamed)'}</span>
|
||||
<span className="font-semibold text-sm truncate">{c.name || t('udpp.unnamed')}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{svc?.label ?? c.service_type}
|
||||
{svc ? t(svc.label) : c.service_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
@@ -269,6 +282,7 @@ function EditDialog({
|
||||
onCancel: () => void;
|
||||
onSave: (c: UDPConfig) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = useState<UDPConfig>(cfg);
|
||||
// Service-type list filtered to this connection's direction.
|
||||
const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction);
|
||||
@@ -292,31 +306,33 @@ function EditDialog({
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{cfg.id === 0 ? 'New' : 'Edit'} {draft.direction} connection
|
||||
{t('udpp.dialogTitle', {
|
||||
action: cfg.id === 0 ? t('udpp.new') : t('udpp.edit'),
|
||||
direction: draft.direction === 'inbound' ? t('udpp.directionInbound') : t('udpp.directionOutbound'),
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentService?.hint}
|
||||
{currentService ? t(currentService.hint) : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Name</Label>
|
||||
<Label>{t('udpp.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={draft.direction === 'inbound' ? 'WSJT-X log' : 'Cloudlog notify'}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Service type</Label>
|
||||
<Label>{t('udpp.serviceType')}</Label>
|
||||
<Select value={draft.service_type} onValueChange={(v) => applyPreset(v as UDPConfig['service_type'])}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
<SelectItem key={s.id} value={s.id}>{t(s.label)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -324,7 +340,7 @@ function EditDialog({
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Label>{t('udpp.port')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1} max={65535}
|
||||
@@ -341,13 +357,13 @@ function EditDialog({
|
||||
checked={draft.multicast}
|
||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
|
||||
/>
|
||||
<span>Multicast</span>
|
||||
<span>{t('udpp.multicast')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.multicast && (
|
||||
<div className="space-y-1">
|
||||
<Label>Multicast group</Label>
|
||||
<Label>{t('udpp.multicastGroup')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="224.0.0.1"
|
||||
@@ -355,14 +371,14 @@ function EditDialog({
|
||||
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Use the same group address as the sending app. WSJT-X default is 224.0.0.1.
|
||||
{t('udpp.multicastHint')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.direction === 'outbound' && (
|
||||
<div className="space-y-1">
|
||||
<Label>Destination IP</Label>
|
||||
<Label>{t('udpp.destinationIp')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="127.0.0.1"
|
||||
@@ -377,17 +393,17 @@ function EditDialog({
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
<span>{t('udpp.enabled')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>{t('udpp.cancel')}</Button>
|
||||
<Button
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={!draft.name.trim() || !draft.port}
|
||||
>
|
||||
Save
|
||||
{t('udpp.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Radio, Square, Send, Plug, Power, RefreshCw, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface WKMacro { label: string; text: string }
|
||||
export interface WKStatus {
|
||||
connected: boolean;
|
||||
busy: boolean;
|
||||
wpm: number;
|
||||
version: number;
|
||||
port: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
status: WKStatus;
|
||||
ports: string[];
|
||||
port: string;
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
onRefreshPorts: () => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onSetSpeed: (wpm: number) => void;
|
||||
onSend: (text: string) => void; // raw text (App resolves variables)
|
||||
onSendMacro: (index: number) => void; // App resolves the macro + sends
|
||||
onStop: () => void;
|
||||
onClose: () => void; // disable the keyer (hide the panel)
|
||||
sendOnType: boolean;
|
||||
onToggleSendOnType: (on: boolean) => void;
|
||||
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
||||
onBackspace: () => void; // remove last not-yet-keyed char
|
||||
autoCall: boolean; // repeat the clicked macro on a timer
|
||||
autoCallSecs: number; // gap (s) after the message before repeating
|
||||
onToggleAutoCall: (on: boolean) => void;
|
||||
onSetAutoCallSecs: (n: number) => void;
|
||||
}
|
||||
|
||||
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
||||
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
||||
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
||||
export function WinkeyerPanel({
|
||||
status, ports, port, wpm, macros, sent, source, breakIn = 0, onSetBreakIn,
|
||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||
onSend, onSendMacro, onStop, onClose,
|
||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [speed, setSpeed] = useState(wpm);
|
||||
// Step the speed (compact +/- control replaces the old slider).
|
||||
const changeSpeed = (delta: number) => {
|
||||
const w = Math.max(5, Math.min(50, speed + delta));
|
||||
setSpeed(w);
|
||||
onSetSpeed(w);
|
||||
};
|
||||
|
||||
// Keep the local speed in sync when the device/config changes it.
|
||||
useEffect(() => { setSpeed(status.connected ? status.wpm || wpm : wpm); }, [status.wpm, status.connected, wpm]);
|
||||
|
||||
const connected = status.connected;
|
||||
|
||||
function sendText() {
|
||||
const t = cwText.trim();
|
||||
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
|
||||
setCwText('');
|
||||
}
|
||||
|
||||
// In "send on type" mode, key each newly-typed char immediately, and send a
|
||||
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
||||
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
||||
function onCwChange(v: string) {
|
||||
if (sendOnType && connected) {
|
||||
const old = cwText;
|
||||
if (v.length > old.length && v.startsWith(old)) {
|
||||
onSendRaw(v.slice(old.length));
|
||||
} else if (v.length < old.length && old.startsWith(v)) {
|
||||
for (let i = 0; i < old.length - v.length; i++) onBackspace();
|
||||
}
|
||||
}
|
||||
setCwText(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-2 h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||
{/* Header / connection bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ports.length === 0 && <SelectItem value="_" disabled>{t('wkp.noPorts')}</SelectItem>}
|
||||
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.refreshPorts')} onClick={onRefreshPorts}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
|
||||
<Plug className="size-3.5" /> {t('wkp.connect')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
|
||||
<Power className="size-3.5" /> {t('wkp.disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.hide')} onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 px-3 pb-2 min-h-0 overflow-y-auto">
|
||||
{/* Live transmitted text (echoed by the keyer) + compact speed stepper. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-8 shrink-0">TX</Label>
|
||||
<div className={cn(
|
||||
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
||||
status.busy ? 'text-success' : 'text-muted-foreground',
|
||||
)}>
|
||||
{sent || <span className="opacity-50">—</span>}
|
||||
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||
</div>
|
||||
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
|
||||
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.cwSpeed')}>
|
||||
<span className="font-mono text-sm font-bold tabular-nums">{speed}</span>
|
||||
<span className="text-[9px] text-muted-foreground">wpm</span>
|
||||
<div className="flex flex-col -my-0.5">
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title={t('wkp.faster')}
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronUp className="size-3.5" /></button>
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title={t('wkp.slower')}
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Break-in (Icom only): the rig's 0x17 keyer only transmits when break-in
|
||||
is on — SEMI or FULL. OFF keys the sidetone but stays in RX. */}
|
||||
{source === 'icom' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-16 shrink-0" title={t('wkp.breakInHint')}>{t('wkp.breakIn')}</Label>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{[{ v: 0, l: t('wkp.bkOff') }, { v: 1, l: 'SEMI' }, { v: 2, l: 'FULL' }].map((o) => (
|
||||
<button key={o.v} type="button" disabled={!connected} onClick={() => onSetBreakIn?.(o.v)}
|
||||
className={cn('px-2.5 py-1 text-[11px] font-bold tracking-wide border-l border-border first:border-l-0 transition-colors disabled:opacity-40',
|
||||
breakIn === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{breakIn === 0 && <span className="text-[10px] text-warning">{t('wkp.bkOffWarn')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CW text */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
{t('wkp.cwText')}
|
||||
{source === 'winkeyer' && (
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||
{t('wkp.sendOnType')}
|
||||
</label>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
value={cwText}
|
||||
onChange={(e) => onCwChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
disabled={!connected}
|
||||
className="font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
|
||||
someone answers. The seconds box is the gap AFTER the message. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||
title={t('wkp.autoCallHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||
{t('wkp.autoCall')}
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">{t('wkp.gap')}</span>
|
||||
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.gapHint')}>
|
||||
<input type="number" min={0} max={120}
|
||||
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||
</div>
|
||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{macros.map((m, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onSendMacro(i)}
|
||||
disabled={!connected}
|
||||
title={m.text}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-left transition-colors',
|
||||
connected ? 'hover:border-primary/60 hover:bg-accent/40' : 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-primary font-semibold shrink-0">F{i + 1}</span>
|
||||
<span className="text-xs font-medium truncate">{m.label || t('wkp.macroN', { n: i + 1 })}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{status.error && <div className="text-[11px] text-danger">{status.error}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +1,47 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef, type ColumnState, type GridReadyEvent,
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, Star } from 'lucide-react';
|
||||
import { Columns3, FilterX, Star } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
|
||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||
|
||||
type Props = {
|
||||
wb: WorkedBeforeView | null;
|
||||
busy: boolean;
|
||||
currentCall: string;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award (cell = the reference this QSO counts for).
|
||||
awardCols?: { code: string; name: string }[];
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
|
||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||||
|
||||
function fmtDateTime(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
function fmtDate(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
@@ -62,82 +50,89 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
const bandPill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
const modePill = (p: any) => p.value
|
||||
? <span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
||||
}}>{p.value}</span>
|
||||
: '';
|
||||
|
||||
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
|
||||
const flagRenderer = (p: any) => {
|
||||
if (p.value === 'Y') {
|
||||
return <span style={{
|
||||
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
|
||||
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
|
||||
fontSize: 10, fontWeight: 700, lineHeight: '16px',
|
||||
}}>Y</span>;
|
||||
}
|
||||
return <span style={{ color: '#a8a29e' }}>—</span>;
|
||||
};
|
||||
|
||||
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onCellContextMenu(e: any) {
|
||||
const ev = e.event as MouseEvent | undefined;
|
||||
ev?.preventDefault();
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
if (e.node && !e.node.isSelected()) {
|
||||
api.deselectAll();
|
||||
e.node.setSelected(true);
|
||||
}
|
||||
const ids = (api.getSelectedRows() as WorkedEntry[])
|
||||
.map((r) => r.id as number)
|
||||
.filter((n) => !!n);
|
||||
if (ids.length === 0) return;
|
||||
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
|
||||
}
|
||||
|
||||
const hasCall = currentCall.trim() !== '';
|
||||
const count = wb?.count ?? 0;
|
||||
const entries = wb?.entries ?? [];
|
||||
|
||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
|
||||
// so the default visibility doesn't clobber the user's saved state. See the
|
||||
// matching note in RecentQSOsGrid.
|
||||
const restoringRef = useRef(true);
|
||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
const base = COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
});
|
||||
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
headerTooltip: t('wbg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
cellClass: 'text-[11px]',
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols, COL_CATALOG, t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ColumnState[];
|
||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
try {
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
|
||||
// Re-apply the saved column state after the award columns load (they rebuild
|
||||
// the column set), so the user's visibility choices win over the defaults.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
@@ -176,14 +171,14 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
if (!hasCall) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign in the entry strip to see prior contacts.
|
||||
{t('wbg.typeCall')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (busy && count === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
{t('wbg.checking')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,8 +186,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-8 text-primary fill-current" />
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">{t('wbg.new')}</div>
|
||||
<div>{t('wbg.noPriorPre')}<span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>{t('wbg.noPriorPost')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -201,25 +196,29 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.workedBefore')}</span>
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
|
||||
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
{t('wbg.first')} <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
{t('wbg.last')} <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
{wb?.dxcc_name && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{t('wbg.dxcc')} <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
|
||||
<span> · {wb.dxcc_count} entity QSOs</span>
|
||||
<span> · {t('wbg.entityQsos', { n: wb.dxcc_count })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title={t('wbg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('wbg.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('wbg.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -237,6 +236,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
preventDefaultOnContextMenu
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
@@ -244,31 +247,43 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QSOContextMenu
|
||||
menu={menu}
|
||||
onClose={() => setMenu(null)}
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onUpdateFromClublog={onUpdateFromClublog}
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||||
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||||
{t('wbg.olderQsos', { n: count - entries.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Worked-before columns</DialogTitle>
|
||||
<DialogTitle>{t('wbg.pickerTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Worked-before table.
|
||||
{t('wbg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
if (cols.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('wbg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('wbg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -285,10 +300,30 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{awardCols && awardCols.length > 0 && (
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.grpAwards')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('wbg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('wbg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{awardCols.map((a) => (
|
||||
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
||||
<span className="font-mono font-semibold">{a.code}</span>
|
||||
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('wbg.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('wbg.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on
|
||||
// log (auto-send on, recipient e-mail known, default template, not already
|
||||
// sent) and emits "qsl:autosend"; this component does the part Go can't —
|
||||
// render the card off-screen, rasterize it to JPEG and hand it back to
|
||||
// SendEQSL. Jobs are processed one at a time. Mounted once, near the app root.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { EventsOn } from '../../../wailsjs/runtime/runtime';
|
||||
import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App';
|
||||
import type { RenderModel } from './qslTypes';
|
||||
import { loadCardAssets, type CardAssets } from './qslAssets';
|
||||
import { CardPreview } from './CardPreview';
|
||||
import { rasterizeCard } from './rasterize';
|
||||
|
||||
interface Job {
|
||||
qsoId: number;
|
||||
templateId: number;
|
||||
callsign: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSent?: (callsign: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function AutoEQSL({ onSent, onError }: Props) {
|
||||
const queue = useRef<Job[]>([]);
|
||||
const busy = useRef(false);
|
||||
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
||||
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
|
||||
|
||||
// Keep the callbacks in refs so they never change the effects' identity — a
|
||||
// toast/grid re-render from onSent must NOT re-run the send effect (that
|
||||
// re-sent the same eQSL many times in a row).
|
||||
const onSentRef = useRef(onSent);
|
||||
const onErrorRef = useRef(onError);
|
||||
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
|
||||
|
||||
// Pull the next job, fetch its render model + assets, then mount it (the
|
||||
// effect below rasterizes once the DOM has it).
|
||||
const pump = useCallback(async () => {
|
||||
if (busy.current) return;
|
||||
const job = queue.current.shift();
|
||||
if (!job) return;
|
||||
busy.current = true;
|
||||
try {
|
||||
const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel;
|
||||
const assets = await loadCardAssets(model.template, job.templateId);
|
||||
setCurrent({ job, model, assets });
|
||||
} catch (e) {
|
||||
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||
busy.current = false;
|
||||
void pump();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
|
||||
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
|
||||
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
|
||||
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
|
||||
void pump();
|
||||
});
|
||||
return () => off();
|
||||
}, [pump]);
|
||||
|
||||
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
|
||||
// Sends exactly once per job (guarded by sentForRef), independent of renders.
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
if (sentForRef.current === current.job.qsoId) return; // already sent this one
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
||||
if (cancelled || !svgEl.current) return;
|
||||
sentForRef.current = current.job.qsoId;
|
||||
const card = current.model.template.card;
|
||||
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
||||
onSentRef.current?.(current.job.callsign);
|
||||
} catch (e) {
|
||||
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setCurrent(null);
|
||||
busy.current = false;
|
||||
void pump();
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [current, pump]);
|
||||
|
||||
if (!current) return null;
|
||||
// Off-screen at full card resolution so the rasterized output matches the
|
||||
// editor preview exactly.
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ position: 'fixed', left: -100000, top: 0, opacity: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<CardPreview
|
||||
model={current.model}
|
||||
assets={current.assets}
|
||||
width={current.model.template.card.w}
|
||||
svgRef={(el) => { svgEl.current = el; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
// CardPreview — the single SVG rendering implementation for the QSL card.
|
||||
// Proposals, the live editor, thumbnails and final rasterization ALL go
|
||||
// through this component so the output can never drift from the preview.
|
||||
// Style presets are layer-stack recipes (see internal/qslcard/presets.go and
|
||||
// section 4 of the design spec); the gel stack renders the text 7 times,
|
||||
// back to front: halo, drop shadow, outline, bevel base, bevel light edge,
|
||||
// gradient face, wavy gloss band clipped to the glyphs.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
|
||||
import { QSO_FIELD_LABELS } from './qslTypes';
|
||||
import type { CardAssets } from './qslAssets';
|
||||
import { FONT_WEIGHTS } from './qslAssets';
|
||||
import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx';
|
||||
|
||||
// An editor selection: an element index or the QSO box.
|
||||
export type CardSelection = number | 'box' | null;
|
||||
|
||||
// Fraction of the font size between a glyph's em-box top and its cap line.
|
||||
// Used to make a text element's y align to the visible letter tops.
|
||||
const CAP_INSET = 0.18;
|
||||
|
||||
interface Props {
|
||||
model: RenderModel;
|
||||
assets: CardAssets;
|
||||
width: number; // display width in CSS px
|
||||
selected?: CardSelection;
|
||||
onSelect?: (sel: CardSelection) => void;
|
||||
onMove?: (sel: Exclude<CardSelection, null>, x: number, y: number) => void;
|
||||
svgRef?: (el: SVGSVGElement | null) => void;
|
||||
}
|
||||
|
||||
// Fallbacks mirroring the preset defaults in presets.go, applied when a
|
||||
// document omits a knob the stack needs.
|
||||
const GOLD = ['#FFD83A', '#FFC312', '#EE9400'];
|
||||
const DEF_SHINE = { coverage: 0.5, opacity: 0.95 };
|
||||
const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 };
|
||||
const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 };
|
||||
const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' };
|
||||
|
||||
// fontSpec maps a template font name to a concrete family + weight.
|
||||
// "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed.
|
||||
function fontSpec(font?: string): { family: string; weight: number | 'normal' } {
|
||||
if (!font || font === 'system-bold-sans') {
|
||||
return { family: "'Segoe UI', Arial, sans-serif", weight: 800 };
|
||||
}
|
||||
return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' };
|
||||
}
|
||||
|
||||
// Presets whose call text is rendered with the ported canvas FX (the only way
|
||||
// to get the exact glossy/western look). Everything else stays plain SVG text.
|
||||
const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']);
|
||||
const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']);
|
||||
function fxKindFor(preset?: string): TextFxKind | null {
|
||||
if (preset && FX_GLOSSY.has(preset)) return 'glossy';
|
||||
if (preset && FX_WESTERN.has(preset)) return 'western';
|
||||
return null;
|
||||
}
|
||||
function buildFxParams(e: CardElement): TextFxParams | null {
|
||||
const kind = fxKindFor(e.style_preset);
|
||||
if (!kind || !e.size) return null;
|
||||
const fs = fontSpec(e.font);
|
||||
const sp = e.style_params ?? {};
|
||||
const fx = sp.fx ?? {};
|
||||
const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']);
|
||||
const silver = e.style_preset?.includes('silver');
|
||||
return {
|
||||
kind,
|
||||
text: e.text ?? '',
|
||||
weight: String(fs.weight === 'normal' ? 400 : fs.weight),
|
||||
family: fs.family,
|
||||
size: e.size,
|
||||
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
||||
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
|
||||
// Dark inter-letter edge + rim — from the chosen colour palette, else the
|
||||
// default near-black edge (the navy outline adaptStyle sets is for the old
|
||||
// SVG stack, not this look).
|
||||
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
|
||||
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
|
||||
// Per-call overrides from the editor (undefined → renderer default).
|
||||
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
||||
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
|
||||
depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge,
|
||||
bevel: fx.bevel, seed: fx.seed,
|
||||
};
|
||||
}
|
||||
// Stable signature of an element's FX-relevant fields, so dragging (x/y only)
|
||||
// doesn't trigger an expensive re-render of the canvas text.
|
||||
function fxSignature(e: CardElement): string {
|
||||
if (!fxKindFor(e.style_preset)) return '';
|
||||
return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|');
|
||||
}
|
||||
|
||||
function elementTransform(e: CardElement): string {
|
||||
const r = e.rotate ? ` rotate(${e.rotate})` : '';
|
||||
return `translate(${e.x} ${e.y})${r}`;
|
||||
}
|
||||
|
||||
// Shared <text> layer of a style stack. All layers must carry identical
|
||||
// glyph geometry — only paint/transform/filter differ.
|
||||
function Layer({
|
||||
e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder,
|
||||
}: {
|
||||
e: CardElement;
|
||||
content: string;
|
||||
fill: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
transform?: string;
|
||||
filter?: string;
|
||||
opacity?: number;
|
||||
paintOrder?: 'stroke';
|
||||
}) {
|
||||
const f = fontSpec(e.font);
|
||||
return (
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontFamily={f.family}
|
||||
fontWeight={f.weight}
|
||||
fontSize={e.size}
|
||||
dominantBaseline="text-before-edge"
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
paintOrder={paintOrder}
|
||||
transform={transform}
|
||||
filter={filter}
|
||||
opacity={opacity}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{content}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
// glossWavePath builds the gel highlight band: full glyph width, bottom
|
||||
// edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period
|
||||
// ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line
|
||||
// is what produces the "gel" look; the band is clipped to the glyphs.
|
||||
function glossWavePath(e: CardElement, content: string, coverage: number): string {
|
||||
const size = e.size ?? 100;
|
||||
const w = 0.78 * size * Math.max(content.length, 1);
|
||||
const capTop = 0.16 * size; // cap height starts below the em-box top
|
||||
const bottom = capTop + coverage * 0.72 * size;
|
||||
const amp = 0.045 * size;
|
||||
const period = 0.25 * size;
|
||||
let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`;
|
||||
let i = 0;
|
||||
for (let x = w; x > 0.1; x -= period, i++) {
|
||||
const x2 = Math.max(0, x - period);
|
||||
const mid = (x + x2) / 2;
|
||||
const dy = i % 2 === 0 ? amp : -amp;
|
||||
d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`;
|
||||
}
|
||||
return d + ' Z';
|
||||
}
|
||||
|
||||
// TextStack renders one text element through its style preset.
|
||||
function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) {
|
||||
const p: StyleParams = e.style_params ?? {};
|
||||
const preset = e.style_preset ?? 'flat_modern';
|
||||
const ow = p.outline_width ?? 0;
|
||||
|
||||
if (preset === 'flat_modern') {
|
||||
return <Layer e={e} content={content} fill={p.color ?? '#ffffff'} />;
|
||||
}
|
||||
if (preset === 'script_white' || preset === 'outlined_white') {
|
||||
return (
|
||||
<>
|
||||
{p.shadow && (
|
||||
<Layer
|
||||
e={e} content={content} fill={p.shadow.color} stroke={p.shadow.color}
|
||||
strokeWidth={2} transform={`translate(${p.shadow.dx} ${p.shadow.dy})`}
|
||||
filter={`url(#qsl-blur-sh-${idx})`} opacity={p.shadow.opacity}
|
||||
/>
|
||||
)}
|
||||
<Layer
|
||||
e={e} content={content} fill={p.color ?? '#ffffff'}
|
||||
stroke={p.outline_color ?? '#22364e'} strokeWidth={ow || 2} paintOrder="stroke"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Gel family + classic white outline: the layered stack.
|
||||
const gradient = p.gradient ?? GOLD;
|
||||
const shadow = p.shadow ?? DEF_SHADOW;
|
||||
const outline = p.outline_color ?? '#2a3f5c';
|
||||
const outlineW = ow || 9;
|
||||
const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge';
|
||||
const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge;
|
||||
const halo = p.halo ?? DEF_HALO;
|
||||
const bevel = p.bevel_offset ?? DEF_BEVEL;
|
||||
const shine = p.shine ?? DEF_SHINE;
|
||||
const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined;
|
||||
return (
|
||||
<>
|
||||
{isGel && (
|
||||
<Layer
|
||||
e={e} content={content} fill="none" stroke={halo.color}
|
||||
strokeWidth={outlineW * 2.5} filter={`url(#qsl-blur-halo-${idx})`} opacity={halo.opacity}
|
||||
/>
|
||||
)}
|
||||
<Layer
|
||||
e={e} content={content} fill={shadow.color} stroke={shadow.color} strokeWidth={2}
|
||||
transform={`translate(${shadow.dx} ${shadow.dy})`}
|
||||
filter={`url(#qsl-blur-sh-${idx})`} opacity={shadow.opacity}
|
||||
/>
|
||||
<Layer
|
||||
e={e} content={content} fill={outline} stroke={outline}
|
||||
strokeWidth={outlineW} paintOrder="stroke"
|
||||
/>
|
||||
{isGel && <Layer e={e} content={content} fill={bevel.dark} />}
|
||||
{isGel && (
|
||||
<Layer
|
||||
e={e} content={content} fill={bevel.light}
|
||||
transform={`translate(${2 * bevel.dx} ${2 * bevel.dy})`}
|
||||
/>
|
||||
)}
|
||||
<Layer e={e} content={content} fill={`url(#qsl-grad-${idx})`} transform={faceT} />
|
||||
{isGel && (
|
||||
<path
|
||||
d={glossWavePath(e, content, shine.coverage)}
|
||||
fill={`url(#qsl-gloss-${idx})`}
|
||||
clipPath={`url(#qsl-clip-${idx})`}
|
||||
transform={faceT}
|
||||
opacity={shine.opacity}
|
||||
/>
|
||||
)}
|
||||
{/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
|
||||
{isGrunge && (
|
||||
<rect
|
||||
x={0} y={0}
|
||||
width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
|
||||
height={(e.size ?? 0) * 1.05}
|
||||
fill="#241405"
|
||||
filter={`url(#qsl-grunge-${idx})`}
|
||||
clipPath={`url(#qsl-clip-${idx})`}
|
||||
transform={faceT}
|
||||
opacity={0.6}
|
||||
/>
|
||||
)}
|
||||
{/* defs that belong to this stack (unique ids per element index) */}
|
||||
<defs>
|
||||
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
|
||||
{gradient.map((c, i) => (
|
||||
<stop key={i} offset={`${(i / Math.max(gradient.length - 1, 1)) * 100}%`} stopColor={c} />
|
||||
))}
|
||||
</linearGradient>
|
||||
{isGel && (
|
||||
<>
|
||||
<linearGradient id={`qsl-gloss-${idx}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
<clipPath id={`qsl-clip-${idx}`}>
|
||||
<text
|
||||
x={0} y={0} fontFamily={fontSpec(e.font).family} fontWeight={fontSpec(e.font).weight}
|
||||
fontSize={e.size} dominantBaseline="text-before-edge"
|
||||
>
|
||||
{content}
|
||||
</text>
|
||||
</clipPath>
|
||||
<filter id={`qsl-blur-halo-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation={halo.blur} />
|
||||
</filter>
|
||||
</>
|
||||
)}
|
||||
{isGrunge && (
|
||||
<filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
|
||||
{/* sparse dark specks: fractal noise → threshold into the alpha */}
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
|
||||
<feColorMatrix in="n" type="matrix"
|
||||
values="0 0 0 0 0.14 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 6 -4.2" />
|
||||
</filter>
|
||||
)}
|
||||
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation={shadow.blur} />
|
||||
</filter>
|
||||
</defs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// QSO box field column weights — the date string is far wider than the rest,
|
||||
// so equal columns let it spill into the next field. Wider weight = more room.
|
||||
const QSO_FIELD_WEIGHT: Record<string, number> = {
|
||||
qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1,
|
||||
};
|
||||
|
||||
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
||||
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
|
||||
const avail = box.w - 56;
|
||||
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||
let cursor = 28;
|
||||
const cols = box.fields.map((f) => {
|
||||
const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total;
|
||||
const col = { f, x: cursor, w };
|
||||
cursor += w;
|
||||
return col;
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{box.title}
|
||||
</text>
|
||||
{cols.map(({ f, x }) => (
|
||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||
<text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||
</text>
|
||||
<text y={26} fontSize={28} fontWeight={700} fill={fg}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{values[f] ?? ''}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{box.footer && (
|
||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||
{box.footer}
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) {
|
||||
const { template: t, qso_fields } = model;
|
||||
const card = t.card;
|
||||
const scale = card.w / width;
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const groupRefs = useRef(new Map<string, SVGGElement>());
|
||||
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
|
||||
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
|
||||
const measureCanvas = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
// Embedded fonts may still be loading when the card first mounts; the canvas
|
||||
// FX needs them for correct metrics, so (re)render once they're ready.
|
||||
const [fontsReady, setFontsReady] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve();
|
||||
void ready.then(() => { if (alive) setFontsReady(true); });
|
||||
return () => { alive = false; };
|
||||
}, [assets]);
|
||||
|
||||
// Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields
|
||||
// so dragging doesn't regenerate it. Maps element index → PNG + size.
|
||||
const fxSig = t.elements.map(fxSignature).join(';');
|
||||
const fxImages = useMemo(() => {
|
||||
const map = new Map<number, TextFxResult>();
|
||||
if (!fontsReady) return map;
|
||||
t.elements.forEach((e, idx) => {
|
||||
const params = buildFxParams(e);
|
||||
if (!params) return;
|
||||
try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ }
|
||||
});
|
||||
return map;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fxSig, fontsReady]);
|
||||
|
||||
// Measure the selected group for the dashed selection outline. For plain SVG
|
||||
// text we use the glyphs' real ink metrics — getBBox would span the font's
|
||||
// full ascent/descent. FX call images fall through to getBBox (already tight).
|
||||
useEffect(() => {
|
||||
if (selected === null || selected === undefined) { setSelBox(null); return; }
|
||||
const key = String(selected);
|
||||
const g = groupRefs.current.get(key);
|
||||
if (!g) { setSelBox(null); return; }
|
||||
const transform = g.getAttribute('transform') ?? '';
|
||||
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
|
||||
const isFx = el ? !!fxKindFor(el.style_preset) : false;
|
||||
if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) {
|
||||
const cv = (measureCanvas.current ??= document.createElement('canvas'));
|
||||
const ctx = cv.getContext('2d');
|
||||
if (ctx) {
|
||||
const f = fontSpec(el.font);
|
||||
ctx.font = `${f.weight} ${el.size}px ${f.family}`;
|
||||
const m = ctx.measureText(el.text ?? '');
|
||||
const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8;
|
||||
const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift
|
||||
const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7);
|
||||
const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0);
|
||||
const left = -(m.actualBoundingBoxLeft ?? 0);
|
||||
const right = m.actualBoundingBoxRight ?? m.width;
|
||||
setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const b = g.getBBox();
|
||||
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
|
||||
}, [selected, model, assets, width, t.elements, fxImages]);
|
||||
|
||||
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
|
||||
(ev: React.PointerEvent<SVGGElement>) => {
|
||||
onSelect?.(sel);
|
||||
if (!onMove) return;
|
||||
ev.stopPropagation();
|
||||
drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy };
|
||||
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: React.PointerEvent<SVGSVGElement>) => {
|
||||
const d = drag.current;
|
||||
if (!d || !onMove) return;
|
||||
const x = Math.round(d.ox + (ev.clientX - d.sx) * scale);
|
||||
const y = Math.round(d.oy + (ev.clientY - d.sy) * scale);
|
||||
onMove(d.sel, x, y);
|
||||
};
|
||||
const endDrag = () => { drag.current = null; };
|
||||
|
||||
const hero = assets.photos[t.hero.photo];
|
||||
const crop = t.hero.crop;
|
||||
|
||||
const setGroupRef = (key: string) => (el: SVGGElement | null) => {
|
||||
if (el) groupRefs.current.set(key, el);
|
||||
else groupRefs.current.delete(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={(el) => { rootRef.current = el; svgRef?.(el); }}
|
||||
viewBox={`0 0 ${card.w} ${card.h}`}
|
||||
width={width}
|
||||
height={Math.round(width * (card.h / card.w))}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ display: 'block', borderRadius: 4 }}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerLeave={endDrag}
|
||||
onPointerDown={() => onSelect?.(null)}
|
||||
>
|
||||
<defs>
|
||||
{/* Fonts must live INSIDE the SVG so rasterization (SVG → <img> →
|
||||
canvas) sees them; document styles don't apply there. */}
|
||||
<style>{assets.fontCss}</style>
|
||||
<filter id="qsl-insert-shadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="6" dy="10" stdDeviation="8" floodColor="#0a1422" floodOpacity="0.45" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* hero photo, source crop mapped onto the full card */}
|
||||
<rect width={card.w} height={card.h} fill="#1a2433" />
|
||||
{hero && crop.w > 0 && crop.h > 0 && (
|
||||
<image
|
||||
href={hero.url}
|
||||
x={(-crop.x * card.w) / crop.w}
|
||||
y={(-crop.y * card.h) / crop.h}
|
||||
width={(hero.w * card.w) / crop.w}
|
||||
height={(hero.h * card.h) / crop.h}
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
)}
|
||||
{t.hero.scrim?.enabled && (
|
||||
<rect width={card.w} height={card.h} fill={t.hero.scrim.color} opacity={t.hero.scrim.opacity} />
|
||||
)}
|
||||
|
||||
{t.elements.map((e, idx) => {
|
||||
const key = String(idx);
|
||||
if (e.hidden) return null; // toggled off in the editor
|
||||
if (e.type === 'insert') {
|
||||
const ph = assets.photos[e.photo ?? ''];
|
||||
if (!ph || !e.w) return null;
|
||||
const h = (e.w * ph.h) / ph.w;
|
||||
const b = e.border_px ?? 0;
|
||||
return (
|
||||
<g
|
||||
key={key} ref={setGroupRef(key)}
|
||||
transform={`translate(${e.x} ${e.y})${e.rotate ? ` rotate(${e.rotate} ${e.w / 2} ${h / 2})` : ''}`}
|
||||
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||
style={{ cursor: onMove ? 'move' : undefined }}
|
||||
>
|
||||
<rect x={-b} y={-b} width={e.w + 2 * b} height={h + 2 * b} fill="#ffffff"
|
||||
filter={e.shadow ? 'url(#qsl-insert-shadow)' : undefined} />
|
||||
<image href={ph.url} width={e.w} height={h} preserveAspectRatio="none" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (e.type === 'country') {
|
||||
const size = e.size ?? 30;
|
||||
const flagH = size * 1.4;
|
||||
const flagW = flagH * (4 / 3);
|
||||
const hasFlag = !!assets.flagUrl;
|
||||
return (
|
||||
<g
|
||||
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
||||
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||
style={{ cursor: onMove ? 'move' : undefined }}
|
||||
>
|
||||
{hasFlag && <image href={assets.flagUrl} width={flagW} height={flagH} />}
|
||||
<text
|
||||
x={hasFlag ? flagW + 16 : 0} y={flagH / 2}
|
||||
fontSize={size} fontWeight={700} fontFamily="'Segoe UI', Arial, sans-serif"
|
||||
fill="#ffffff" stroke="#22364e" strokeWidth={4} paintOrder="stroke"
|
||||
strokeLinejoin="round" dominantBaseline="middle"
|
||||
>
|
||||
{e.label === 'auto' ? '' : e.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
// Text elements. Glossy/western calls render through the ported canvas
|
||||
// FX as an <image> (its trimmed bounds already give a tight frame, so
|
||||
// no CAP_INSET shim). Everything else is plain SVG text; the inner
|
||||
// translate pulls the glyphs up to the em-box cap line so the element's
|
||||
// y means "top of the visible letters".
|
||||
const fx = fxImages.get(idx);
|
||||
return (
|
||||
<g
|
||||
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
||||
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||
style={{ cursor: onMove ? 'move' : undefined }}
|
||||
>
|
||||
{fx ? (
|
||||
<image href={fx.url} x={0} y={0} width={fx.w} height={fx.h} />
|
||||
) : (
|
||||
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
|
||||
<TextStack e={e} idx={idx} content={e.text ?? ''} />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{t.qso_box?.enabled && (
|
||||
<g
|
||||
ref={setGroupRef('box')}
|
||||
transform={`translate(${t.qso_box.x} ${t.qso_box.y})`}
|
||||
onPointerDown={startDrag('box', t.qso_box.x, t.qso_box.y)}
|
||||
style={{ cursor: onMove ? 'move' : undefined }}
|
||||
>
|
||||
<QSOBoxView box={t.qso_box} values={qso_fields} />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* selection outline (editor only) */}
|
||||
{selBox && onSelect && (
|
||||
<g transform={selBox.t} pointerEvents="none">
|
||||
<rect
|
||||
x={selBox.x - 8} y={selBox.y - 8} width={selBox.w + 16} height={selBox.h + 16}
|
||||
fill="none" stroke="#38bdf8" strokeWidth={3 * scale > 3 ? 3 * scale : 3}
|
||||
strokeDasharray={`${10 * scale} ${6 * scale}`}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// EditorPanel — the designer's right-hand controls: card-level options
|
||||
// (scrim, name, default) and per-selection editing (font, size, rotation,
|
||||
// style preset + params; insert width/border; QSO box geometry).
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
|
||||
import type { CardSelection } from './CardPreview';
|
||||
import { StylePresetPicker, NumberField } from './StylePresetPicker';
|
||||
|
||||
interface Props {
|
||||
template: CardTemplate;
|
||||
sel: CardSelection;
|
||||
presets: QSLPresetInfo[];
|
||||
fontFamilies: string[]; // embedded + detected system faces
|
||||
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
|
||||
onPatchBox: (patch: Partial<QSOBox>) => void;
|
||||
onScrim: (enabled: boolean) => void;
|
||||
onSelect?: (sel: CardSelection) => void;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
callsign: 'Callsign',
|
||||
operator: 'Operator name',
|
||||
info_line: 'Info line',
|
||||
country: 'Country + flag',
|
||||
insert: 'Photo insert',
|
||||
};
|
||||
|
||||
// A readable layer name, disambiguating the two info lines and numbering inserts.
|
||||
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
|
||||
if (el.type === 'info_line') {
|
||||
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
|
||||
}
|
||||
if (el.type === 'insert') {
|
||||
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
|
||||
return `Photo insert ${n}`;
|
||||
}
|
||||
return TYPE_LABELS[el.type] ?? el.type;
|
||||
}
|
||||
|
||||
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
|
||||
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
|
||||
onPatch: (idx: number, patch: Partial<CardElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||
<Select value={e.font} onValueChange={(font) => onPatch(idx, { font })}>
|
||||
<SelectTrigger className="h-8 w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fontFamilies.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f === 'system-bold-sans' ? 'System bold' : f}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<NumberField label="Size" value={e.size ?? 0} min={8} max={500}
|
||||
onChange={(size) => onPatch(idx, { size })} />
|
||||
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-45} max={45}
|
||||
onChange={(rotate) => onPatch(idx, { rotate })} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Text</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={e.text ?? ''}
|
||||
onChange={(ev) => onPatch(idx, { text: ev.target.value })} />
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<StylePresetPicker
|
||||
presets={presets}
|
||||
preset={e.style_preset ?? 'flat_modern'}
|
||||
params={e.style_params ?? {}}
|
||||
onChange={(style_preset, style_params: StyleParams) =>
|
||||
onPatch(idx, { style_preset, style_params })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
|
||||
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
|
||||
const box = template.qso_box;
|
||||
|
||||
return (
|
||||
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
|
||||
<div className="space-y-2 rounded-md border border-border p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={!!template.hero.scrim?.enabled}
|
||||
onCheckedChange={(v) => onScrim(v === true)}
|
||||
/>
|
||||
Darken photo behind text (scrim)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Layers: show/hide each piece, click a name to edit it. */}
|
||||
<div className="space-y-1 rounded-md border border-border p-3">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
|
||||
{template.elements.map((el, i) => (
|
||||
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
|
||||
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
|
||||
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
|
||||
{layerLabel(el, i, template.elements)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{box && (
|
||||
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
|
||||
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
|
||||
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
|
||||
QSO info box
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-border p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
|
||||
</div>
|
||||
{!e && sel !== 'box' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click an element on the card to edit it; drag to move it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{e && (e.type === 'callsign' || e.type === 'operator' || e.type === 'info_line') && (
|
||||
<TextControls e={e} idx={sel as number} presets={presets}
|
||||
fontFamilies={fontFamilies} onPatch={onPatchElement} />
|
||||
)}
|
||||
|
||||
{e && e.type === 'insert' && (
|
||||
<div className="space-y-2">
|
||||
<NumberField label="Width" value={e.w ?? 0} min={80} max={1200}
|
||||
onChange={(w) => onPatchElement(sel as number, { w })} />
|
||||
<NumberField label="Border" value={e.border_px ?? 0} min={0} max={40}
|
||||
onChange={(border_px) => onPatchElement(sel as number, { border_px })} />
|
||||
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-15} max={15}
|
||||
onChange={(rotate) => onPatchElement(sel as number, { rotate })} />
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={!!e.shadow}
|
||||
onCheckedChange={(v) => onPatchElement(sel as number, { shadow: v === true })} />
|
||||
Drop shadow
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{e && e.type === 'country' && (
|
||||
<NumberField label="Size" value={e.size ?? 30} min={16} max={80}
|
||||
onChange={(size) => onPatchElement(sel as number, { size })} />
|
||||
)}
|
||||
|
||||
{sel === 'box' && box && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={box.enabled}
|
||||
onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
|
||||
Show QSO box
|
||||
</label>
|
||||
<NumberField label="Width" value={box.w} min={300} max={1400}
|
||||
onChange={(w) => onPatchBox({ w })} />
|
||||
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||
onChange={(h) => onPatchBox({ h })} />
|
||||
<NumberField label="Opacity %" value={Math.round((box.bg_opacity ?? 1) * 100)} min={0} max={100}
|
||||
onChange={(v) => onPatchBox({ bg_opacity: Math.max(0, Math.min(100, v)) / 100 })} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Background</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(box.bg ?? '') ? box.bg : '#000000'}
|
||||
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||
<Input className="h-7 w-32 font-mono text-xs" value={box.bg ?? ''}
|
||||
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Text color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(box.fg ?? '') ? (box.fg as string) : '#14243a'}
|
||||
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||
<Input className="h-7 w-32 font-mono text-xs" value={box.fg ?? ''} placeholder="#14243a"
|
||||
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<NumberField label="Corner radius" value={box.radius ?? 0} min={0} max={80}
|
||||
onChange={(radius) => onPatchBox({ radius })} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Title</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||
onChange={(ev) => onPatchBox({ footer: ev.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="px-1 text-[11px] leading-snug text-muted-foreground">
|
||||
Text supports {'{profile.*}'} and {'{qso.*}'} placeholders — they fill in
|
||||
automatically on every card.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// QslDesignerModal — the QSL card designer. Flow: drop/choose photos →
|
||||
// "Generate designs" (3 automatic proposals from the placement engine) →
|
||||
// pick one → live editor (click/drag elements, style controls) → save.
|
||||
// Saved templates are listed with thumbnails and can be edited, deleted or
|
||||
// marked default (used by "Send eQSL by e-mail" on the QSO grid).
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Images, Loader2, Sparkles, Star, Trash2, Pencil, ChevronLeft } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||
} from '../../../wailsjs/go/main/App';
|
||||
import type {
|
||||
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
|
||||
} from './qslTypes';
|
||||
import { loadCardAssets, loadFonts, type CardAssets } from './qslAssets';
|
||||
import { CardPreview, type CardSelection } from './CardPreview';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { rasterizeCard } from './rasterize';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Proposal {
|
||||
template: CardTemplate;
|
||||
model: RenderModel;
|
||||
assets: CardAssets;
|
||||
}
|
||||
|
||||
interface Editing {
|
||||
templateId: number; // 0 = new (photos still at their original paths)
|
||||
template: CardTemplate; // raw document ({placeholders})
|
||||
model: RenderModel; // resolved copy for display
|
||||
assets: CardAssets;
|
||||
}
|
||||
|
||||
type View = 'home' | 'proposals' | 'editor';
|
||||
|
||||
async function resolveModel(template: CardTemplate): Promise<RenderModel> {
|
||||
return JSON.parse(await QSLResolvePreview(JSON.stringify(template))) as RenderModel;
|
||||
}
|
||||
|
||||
export function QslDesignerModal({ open, onClose }: Props) {
|
||||
const [view, setView] = useState<View>('home');
|
||||
const [photoPaths, setPhotoPaths] = useState<string[]>([]);
|
||||
const [proposals, setProposals] = useState<Proposal[]>([]);
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const [sel, setSel] = useState<CardSelection>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [asDefault, setAsDefault] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||
const [deleteArm, setDeleteArm] = useState(0);
|
||||
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setView('home');
|
||||
setError('');
|
||||
setPhotoPaths([]);
|
||||
setProposals([]);
|
||||
setEditing(null);
|
||||
void refreshSaved();
|
||||
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
|
||||
void loadFonts().then(({ fonts }) =>
|
||||
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
|
||||
}, [open]);
|
||||
|
||||
async function refreshSaved() {
|
||||
try {
|
||||
const list = ((await QSLListTemplates()) ?? []) as QSLTemplateInfo[];
|
||||
setSaved(list);
|
||||
const p: Record<number, string> = {};
|
||||
await Promise.all(list.map(async (t) => {
|
||||
const url = await QSLPreviewDataURL(t.id);
|
||||
if (url) p[t.id] = url;
|
||||
}));
|
||||
setPreviews(p);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function choosePhotos() {
|
||||
try {
|
||||
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
||||
if (paths.length) setPhotoPaths(paths.slice(0, 5));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const docs = (await QSLGenerateProposals(photoPaths)) as string[];
|
||||
const out: Proposal[] = [];
|
||||
for (const doc of docs) {
|
||||
const template = JSON.parse(doc) as CardTemplate;
|
||||
const model = await resolveModel(template);
|
||||
const assets = await loadCardAssets(model.template, 0);
|
||||
out.push({ template, model, assets });
|
||||
}
|
||||
setProposals(out);
|
||||
setView('proposals');
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(templateId: number, template: CardTemplate, model: RenderModel, assets: CardAssets) {
|
||||
setEditing({ templateId, template, model, assets });
|
||||
setName(template.name);
|
||||
setAsDefault(false);
|
||||
setSel(null);
|
||||
setError('');
|
||||
setView('editor');
|
||||
}
|
||||
|
||||
async function editSaved(info: QSLTemplateInfo) {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const template = JSON.parse(await QSLGetTemplate(info.id)) as CardTemplate;
|
||||
const model = await resolveModel(template);
|
||||
const assets = await loadCardAssets(model.template, info.id);
|
||||
openEditor(info.id, template, model, assets);
|
||||
setAsDefault(info.is_default);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Edits patch the raw template AND the resolved display copy in lock-step;
|
||||
// only a text change needs a backend re-resolve (placeholders may differ).
|
||||
function patchBoth(fn: (t: CardTemplate) => void, reResolve = false) {
|
||||
setEditing((cur) => {
|
||||
if (!cur) return cur;
|
||||
const template = structuredClone(cur.template);
|
||||
const model = structuredClone(cur.model);
|
||||
fn(template);
|
||||
fn(model.template);
|
||||
if (reResolve) {
|
||||
void resolveModel(template).then((m) =>
|
||||
setEditing((c) => (c ? { ...c, model: m } : c)));
|
||||
}
|
||||
return { ...cur, template, model };
|
||||
});
|
||||
}
|
||||
|
||||
const patchElement = (idx: number, patch: Partial<CardElement>) =>
|
||||
patchBoth((t) => Object.assign(t.elements[idx], patch), 'text' in patch);
|
||||
|
||||
const patchBox = (patch: Partial<QSOBox>) =>
|
||||
patchBoth((t) => { if (t.qso_box) Object.assign(t.qso_box, patch); }, 'footer' in patch || 'title' in patch);
|
||||
|
||||
const onMove = (target: Exclude<CardSelection, null>, x: number, y: number) =>
|
||||
patchBoth((t) => {
|
||||
if (target === 'box') {
|
||||
if (t.qso_box) { t.qso_box.x = x; t.qso_box.y = y; }
|
||||
} else {
|
||||
t.elements[target].x = x;
|
||||
t.elements[target].y = y;
|
||||
}
|
||||
});
|
||||
|
||||
const onScrim = (enabled: boolean) =>
|
||||
patchBoth((t) => {
|
||||
t.hero.scrim = { enabled, color: t.hero.scrim?.color ?? '#0b1a2e', opacity: t.hero.scrim?.opacity ?? 0.25 };
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const id = await QSLSaveTemplate(editing.templateId, name, JSON.stringify(editing.template), true);
|
||||
if (svgEl.current) {
|
||||
const card = editing.template.card;
|
||||
const png = await rasterizeCard(svgEl.current, 480, Math.round(480 * (card.h / card.w)), 'image/png');
|
||||
await QSLSavePreview(id, png);
|
||||
}
|
||||
if (asDefault) await QSLSetDefaultTemplate(id);
|
||||
await refreshSaved();
|
||||
setView('home');
|
||||
setEditing(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplate(id: number) {
|
||||
if (deleteArm !== id) { setDeleteArm(id); return; }
|
||||
setDeleteArm(0);
|
||||
try {
|
||||
await QSLDeleteTemplate(id);
|
||||
await refreshSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function makeDefault(id: number) {
|
||||
try {
|
||||
await QSLSetDefaultTemplate(id);
|
||||
await refreshSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[1260px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-warning" />
|
||||
QSL card designer
|
||||
{view !== 'home' && (
|
||||
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
||||
onClick={() => setView(view === 'editor' && proposals.length && editing?.templateId === 0 ? 'proposals' : 'home')}>
|
||||
<ChevronLeft className="size-3.5" /> Back
|
||||
</Button>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||
)}
|
||||
|
||||
{view === 'home' && (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">New design</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick 1–5 photos — OpsLog analyzes them and proposes three card designs
|
||||
with your callsign, name, zones and country placed automatically.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={choosePhotos}>
|
||||
<Images className="mr-1 size-4" /> Choose photos…
|
||||
</Button>
|
||||
<Button size="sm" disabled={!photoPaths.length || busy} onClick={generate}>
|
||||
{busy ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
|
||||
Generate designs
|
||||
</Button>
|
||||
{photoPaths.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{photoPaths.length} photo{photoPaths.length > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{saved.map((t) => (
|
||||
<div key={t.id} className="rounded-md border border-border p-2">
|
||||
{previews[t.id]
|
||||
? <img src={previews[t.id]} alt={t.name} className="w-full rounded" />
|
||||
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-warning text-warning" />}
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-0.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Edit"
|
||||
onClick={() => editSaved(t)}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
{!t.is_default && (
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Set as default"
|
||||
onClick={() => makeDefault(t.id)}>
|
||||
<Star className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm"
|
||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-destructive' : 'w-7'}`}
|
||||
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The eQSL e-mail message and the auto-send option are in Settings → E-mail (SMTP).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'proposals' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Pick a design to refine it:</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{proposals.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-info"
|
||||
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||
>
|
||||
<CardPreview model={p.model} assets={p.assets} width={352} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'editor' && editing && (
|
||||
<div className="flex gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="rounded-md bg-slate-800/60 p-3">
|
||||
<CardPreview
|
||||
model={editing.model}
|
||||
assets={editing.assets}
|
||||
width={760}
|
||||
selected={sel}
|
||||
onSelect={setSel}
|
||||
onMove={onMove}
|
||||
svgRef={(el) => { svgEl.current = el; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input className="h-8 w-56" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Vietnam sunset" />
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox checked={asDefault} onCheckedChange={(v) => setAsDefault(v === true)} />
|
||||
Default for this profile
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" disabled={busy || !name.trim()} onClick={save}>
|
||||
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Save template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EditorPanel
|
||||
template={editing.template}
|
||||
sel={sel}
|
||||
presets={presets}
|
||||
fontFamilies={fontFamilies}
|
||||
onPatchElement={patchElement}
|
||||
onPatchBox={patchBox}
|
||||
onScrim={onScrim}
|
||||
onSelect={setSel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// SendEQSLModal — per-QSO eQSL flow: render the default template with the
|
||||
// QSO's data, show the exact card, send it as a JPEG e-mail attachment on
|
||||
// explicit confirmation (never automatic). On success the backend stamps
|
||||
// eqsl_sent and emits "qsl:sent" so the grid refreshes.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Mail, CheckCircle2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
QSLDefaultTemplateID, RenderEQSL, SendEQSL,
|
||||
} from '../../../wailsjs/go/main/App';
|
||||
import type { RenderModel } from './qslTypes';
|
||||
import { loadCardAssets, type CardAssets } from './qslAssets';
|
||||
import { CardPreview } from './CardPreview';
|
||||
import { rasterizeCard } from './rasterize';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
qsoId: number | null;
|
||||
onClose: () => void;
|
||||
onOpenDesigner: () => void;
|
||||
}
|
||||
|
||||
export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||
const [model, setModel] = useState<RenderModel | null>(null);
|
||||
const [assets, setAssets] = useState<CardAssets | null>(null);
|
||||
const [templateId, setTemplateId] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !qsoId) return;
|
||||
setModel(null);
|
||||
setAssets(null);
|
||||
setSent(false);
|
||||
setError('');
|
||||
void (async () => {
|
||||
try {
|
||||
const tid = await QSLDefaultTemplateID();
|
||||
setTemplateId(tid);
|
||||
if (!tid) return; // no template yet — offer the designer below
|
||||
const m = JSON.parse(await RenderEQSL(qsoId, tid)) as RenderModel;
|
||||
setModel(m);
|
||||
setAssets(await loadCardAssets(m.template, tid));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
})();
|
||||
}, [open, qsoId]);
|
||||
|
||||
async function send() {
|
||||
if (!qsoId || !model || !svgEl.current) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const card = model.template.card;
|
||||
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||
await SendEQSL(qsoId, templateId, jpeg);
|
||||
setSent(true);
|
||||
// Auto-close shortly after the success tick (the grid refreshes on the
|
||||
// qsl:sent event); the user doesn't need to dismiss it manually.
|
||||
window.setTimeout(onClose, 1100);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[820px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="size-5 text-danger" /> Send OpsLog QSL by e-mail
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||
)}
|
||||
|
||||
{open && !templateId && !error && (
|
||||
<div className="space-y-3 py-2 text-sm">
|
||||
<p>No QSL template yet — design one first (drop a few photos, pick a layout).</p>
|
||||
<Button size="sm" onClick={() => { onClose(); onOpenDesigner(); }}>Open the designer</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templateId > 0 && !model && !error && (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> Preparing the card…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model && assets && (
|
||||
<div className="rounded-md bg-slate-800/60 p-3">
|
||||
<CardPreview model={model} assets={assets} width={740}
|
||||
svgRef={(el) => { svgEl.current = el; }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{sent ? (
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" disabled={!model || busy} onClick={send}>
|
||||
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Send
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// StylePresetPicker — preset choice + per-preset parameter controls for the
|
||||
// selected text element. Only the knobs the preset declares (params) are
|
||||
// shown, matching the backend whitelist so saving can't fail on a stray key.
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { QSLPresetInfo, StyleParams, FxParams } from './qslTypes';
|
||||
|
||||
interface Props {
|
||||
presets: QSLPresetInfo[];
|
||||
preset: string;
|
||||
params: StyleParams;
|
||||
onChange: (preset: string, params: StyleParams) => void;
|
||||
}
|
||||
|
||||
// Quick colour palettes per FX family (mirrors the reference generators):
|
||||
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
|
||||
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
|
||||
const GLOSSY_PALETTES: Palette[] = [
|
||||
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
|
||||
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
|
||||
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
|
||||
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
|
||||
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
|
||||
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
|
||||
];
|
||||
const WESTERN_PALETTES: Palette[] = [
|
||||
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
|
||||
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
|
||||
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
|
||||
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
|
||||
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
|
||||
];
|
||||
|
||||
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<input
|
||||
type="color"
|
||||
className="h-7 w-10 cursor-pointer rounded border border-border bg-transparent"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SliderRow({ label, value, min, max, step, onChange }: {
|
||||
label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
|
||||
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
|
||||
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StylePresetPicker({ presets, preset, params, onChange }: Props) {
|
||||
const info = presets.find((p) => p.name === preset);
|
||||
const has = (k: string) => info?.params.includes(k) ?? false;
|
||||
const set = (patch: Partial<StyleParams>) => onChange(preset, { ...params, ...patch });
|
||||
|
||||
// gel_* presets are rendered by the canvas FX, not the SVG stack: show the FX
|
||||
// knobs instead of the old shine/halo/shadow/outline rows.
|
||||
const isFx = preset.startsWith('gel_');
|
||||
const fxWestern = preset.includes('grunge');
|
||||
const fx: FxParams = params.fx ?? {};
|
||||
const setFx = (patch: Partial<FxParams>) => set({ fx: { ...fx, ...patch } });
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={preset}
|
||||
onValueChange={(name) => {
|
||||
const next = presets.find((p) => p.name === name);
|
||||
// Switching presets resets the params to that preset's defaults —
|
||||
// stale keys from the previous preset would be rejected on save.
|
||||
onChange(name, next ? { ...next.defaults } : {});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presets.map((p) => (
|
||||
<SelectItem key={p.name} value={p.name}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isFx && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Palette</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
|
||||
<button
|
||||
key={p.name} type="button" title={p.name}
|
||||
className="h-6 w-6 rounded border border-border"
|
||||
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
|
||||
onClick={() => onChange(preset, {
|
||||
...params,
|
||||
gradient: [p.top, p.mid, p.bot],
|
||||
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{has('color') && (
|
||||
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
||||
)}
|
||||
{has('gradient') && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Gradient</Label>
|
||||
<div className="flex gap-1">
|
||||
{(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400']).map((c, i) => (
|
||||
<input
|
||||
key={i} type="color" value={c}
|
||||
className="h-7 w-8 cursor-pointer rounded border border-border bg-transparent"
|
||||
onChange={(e) => {
|
||||
const g = [...(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400'])];
|
||||
g[i] = e.target.value;
|
||||
set({ gradient: g });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isFx && has('outline_color') && (
|
||||
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
|
||||
onChange={(v) => set({ outline_color: v })} />
|
||||
)}
|
||||
{!isFx && has('outline_width') && (
|
||||
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
|
||||
onChange={(v) => set({ outline_width: v })} />
|
||||
)}
|
||||
{!isFx && has('shine') && (
|
||||
<SliderRow label="Gloss" value={params.shine?.coverage ?? 0.5} min={0.2} max={0.8} step={0.05}
|
||||
onChange={(v) => set({ shine: { coverage: v, opacity: params.shine?.opacity ?? 0.95 } })} />
|
||||
)}
|
||||
{!isFx && has('halo') && (
|
||||
<SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05}
|
||||
onChange={(v) => set({
|
||||
halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v },
|
||||
})} />
|
||||
)}
|
||||
{!isFx && has('shadow') && (
|
||||
<SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05}
|
||||
onChange={(v) => set({
|
||||
shadow: {
|
||||
dx: params.shadow?.dx ?? 5, dy: params.shadow?.dy ?? 8,
|
||||
blur: params.shadow?.blur ?? 5, color: params.shadow?.color ?? '#14243a', opacity: v,
|
||||
},
|
||||
})} />
|
||||
)}
|
||||
|
||||
{isFx && !fxWestern && (
|
||||
<>
|
||||
<SliderRow label="Plump" value={fx.plump ?? 0.03} min={0} max={0.06} step={0.005}
|
||||
onChange={(v) => setFx({ plump: v })} />
|
||||
<SliderRow label="Gloss" value={fx.gloss ?? 0.85} min={0} max={1} step={0.05}
|
||||
onChange={(v) => setFx({ gloss: v })} />
|
||||
<SliderRow label="Gloss height" value={fx.gloss_h ?? 0.45} min={0.2} max={0.8} step={0.05}
|
||||
onChange={(v) => setFx({ gloss_h: v })} />
|
||||
<SliderRow label="Inner shadow" value={fx.inner_b ?? 0.7} min={0} max={1} step={0.05}
|
||||
onChange={(v) => setFx({ inner_b: v })} />
|
||||
<SliderRow label="Dark edge" value={fx.edge ?? 0.5} min={0} max={1} step={0.05}
|
||||
onChange={(v) => setFx({ edge: v })} />
|
||||
<SliderRow label="Silver rim" value={fx.outerw ?? 0.45} min={0} max={1} step={0.05}
|
||||
onChange={(v) => setFx({ outerw: v })} />
|
||||
</>
|
||||
)}
|
||||
{isFx && fxWestern && (
|
||||
<>
|
||||
<SliderRow label="3D depth" value={fx.depth ?? 12} min={0} max={40} step={1}
|
||||
onChange={(v) => setFx({ depth: v })} />
|
||||
<SliderRow label="Slant" value={fx.slant ?? 0.08} min={-0.4} max={0.4} step={0.02}
|
||||
onChange={(v) => setFx({ slant: v })} />
|
||||
<SliderRow label="Grunge" value={fx.grunge ?? 0.8} min={0} max={1} step={0.05}
|
||||
onChange={(v) => setFx({ grunge: v })} />
|
||||
<SliderRow label="Top light" value={fx.bevel ?? 0.45} min={0} max={1} step={0.05}
|
||||
onChange={(v) => setFx({ bevel: v })} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tiny labelled numeric input shared by the designer's right panel.
|
||||
export function NumberField({ label, value, onChange, min, max }: {
|
||||
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<Input
|
||||
type="number" className="h-7 w-20 text-right" value={value} min={min} max={max}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Asset loading for the QSL card renderer: designer fonts (registered as
|
||||
// @font-face AND kept as CSS text so rasterization can inline them into the
|
||||
// serialized SVG — an SVG drawn through an <img> cannot see document fonts),
|
||||
// photos with their natural dimensions, and the country flag.
|
||||
|
||||
import { QSLFonts, QSLPhotoDataURL, QSLFlagDataURL } from '../../../wailsjs/go/main/App';
|
||||
import type { CardTemplate, QSLFontInfo } from './qslTypes';
|
||||
|
||||
export interface PhotoAsset {
|
||||
url: string; // data URL
|
||||
w: number; // natural pixel size
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CardAssets {
|
||||
photos: Record<string, PhotoAsset>; // keyed by template photo ref
|
||||
flagUrl: string; // data URL, '' when no flag
|
||||
fontCss: string; // @font-face rules with data: sources
|
||||
}
|
||||
|
||||
// Weight each embedded family renders at (variable fonts need an explicit
|
||||
// heavy weight to match the printed-card look).
|
||||
export const FONT_WEIGHTS: Record<string, number> = {
|
||||
'Baloo 2': 800,
|
||||
Oswald: 700,
|
||||
};
|
||||
|
||||
let fontsPromise: Promise<{ css: string; fonts: QSLFontInfo[] }> | null = null;
|
||||
|
||||
// loadFonts fetches the embedded fonts once, registers them with the
|
||||
// document and returns the @font-face CSS for SVG embedding.
|
||||
export function loadFonts(): Promise<{ css: string; fonts: QSLFontInfo[] }> {
|
||||
if (!fontsPromise) {
|
||||
fontsPromise = (async () => {
|
||||
const fonts = (await QSLFonts()) as QSLFontInfo[];
|
||||
const rules = fonts.map((f) => {
|
||||
const weight = f.variable ? '100 900' : 'normal';
|
||||
return `@font-face{font-family:'${f.family}';src:url(data:font/ttf;base64,${f.data_b64}) format('truetype');font-weight:${weight};}`;
|
||||
});
|
||||
const css = rules.join('\n');
|
||||
const el = document.createElement('style');
|
||||
el.dataset.qslFonts = '1';
|
||||
el.textContent = css;
|
||||
document.head.appendChild(el);
|
||||
await document.fonts.ready;
|
||||
return { css, fonts };
|
||||
})();
|
||||
}
|
||||
return fontsPromise;
|
||||
}
|
||||
|
||||
async function imageSize(url: string): Promise<{ w: number; h: number }> {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
await img.decode();
|
||||
return { w: img.naturalWidth, h: img.naturalHeight };
|
||||
}
|
||||
|
||||
// Photos are multi-MB base64 payloads; cache them so the three proposals
|
||||
// (which share the same photo set) and editor re-renders fetch each once.
|
||||
const photoCache = new Map<string, Promise<PhotoAsset>>();
|
||||
|
||||
function loadPhoto(templateId: number, ref: string): Promise<PhotoAsset> {
|
||||
const key = `${templateId}:${ref}`;
|
||||
let p = photoCache.get(key);
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const url = await QSLPhotoDataURL(templateId, ref);
|
||||
const size = await imageSize(url);
|
||||
return { url, ...size };
|
||||
})();
|
||||
photoCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// loadCardAssets resolves everything a template needs to render: each
|
||||
// referenced photo as a data URL with its natural size, the flag and the
|
||||
// font CSS. templateId 0 reads draft photos at their original paths.
|
||||
export async function loadCardAssets(template: CardTemplate, templateId: number): Promise<CardAssets> {
|
||||
const refs = new Set<string>([template.hero.photo]);
|
||||
for (const e of template.elements) {
|
||||
if (e.type === 'insert' && e.photo) refs.add(e.photo);
|
||||
}
|
||||
const photos: Record<string, PhotoAsset> = {};
|
||||
await Promise.all(
|
||||
[...refs].map(async (ref) => {
|
||||
photos[ref] = await loadPhoto(templateId, ref);
|
||||
}),
|
||||
);
|
||||
let flagUrl = '';
|
||||
const country = template.elements.find((e) => e.type === 'country');
|
||||
if (country?.flag && country.flag !== 'auto') {
|
||||
flagUrl = await QSLFlagDataURL(country.flag);
|
||||
}
|
||||
const { css } = await loadFonts();
|
||||
return { photos, flagUrl, fontCss: css };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// TypeScript mirror of the QSL template JSON schema (v1) defined in
|
||||
// internal/qslcard/template.go. Template documents cross the Wails boundary
|
||||
// as JSON strings; these types are the frontend's contract with that schema.
|
||||
|
||||
export interface CardGeom {
|
||||
w: number;
|
||||
h: number;
|
||||
dpi: number;
|
||||
bleed_px: number;
|
||||
}
|
||||
|
||||
export interface CropRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Scrim {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface Hero {
|
||||
photo: string;
|
||||
crop: CropRect;
|
||||
scrim?: Scrim;
|
||||
}
|
||||
|
||||
export interface Shine {
|
||||
coverage: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface HaloFx {
|
||||
color: string;
|
||||
blur: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface ShadowFx {
|
||||
dx: number;
|
||||
dy: number;
|
||||
blur: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface BevelFx {
|
||||
dx: number;
|
||||
dy: number;
|
||||
dark: string;
|
||||
light: string;
|
||||
}
|
||||
|
||||
// FxParams — per-call tuning of the canvas renderer (textFx.ts). All optional;
|
||||
// omitted values fall back to the renderer's per-preset default.
|
||||
export interface FxParams {
|
||||
plump?: number;
|
||||
edge?: number;
|
||||
outerw?: number;
|
||||
gloss?: number;
|
||||
gloss_h?: number;
|
||||
gloss_i?: number;
|
||||
inner_b?: number;
|
||||
depth?: number;
|
||||
angle?: number;
|
||||
slant?: number;
|
||||
grunge?: number;
|
||||
bevel?: number;
|
||||
seed?: number;
|
||||
dark?: string;
|
||||
outer?: string;
|
||||
}
|
||||
|
||||
export interface StyleParams {
|
||||
gradient?: string[];
|
||||
shine?: Shine;
|
||||
outline_color?: string;
|
||||
outline_width?: number;
|
||||
halo?: HaloFx;
|
||||
shadow?: ShadowFx;
|
||||
bevel_offset?: BevelFx;
|
||||
color?: string;
|
||||
fx?: FxParams;
|
||||
}
|
||||
|
||||
export type ElementType = 'callsign' | 'operator' | 'info_line' | 'country' | 'insert';
|
||||
|
||||
export interface CardElement {
|
||||
type: ElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
rotate?: number;
|
||||
hidden?: boolean; // toggled off in the editor
|
||||
// text elements
|
||||
text?: string;
|
||||
font?: string;
|
||||
size?: number;
|
||||
style_preset?: string;
|
||||
style_params?: StyleParams;
|
||||
// country
|
||||
flag?: string;
|
||||
label?: string;
|
||||
// insert
|
||||
photo?: string;
|
||||
w?: number;
|
||||
border_px?: number;
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
export interface QSOBox {
|
||||
enabled: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
bg: string;
|
||||
bg_opacity: number;
|
||||
fg?: string; // text colour (default dark); set it when using a dark background
|
||||
radius: number;
|
||||
title: string;
|
||||
fields: string[];
|
||||
footer: string;
|
||||
}
|
||||
|
||||
export interface CardTemplate {
|
||||
schema: number;
|
||||
name: string;
|
||||
card: CardGeom;
|
||||
hero: Hero;
|
||||
elements: CardElement[];
|
||||
qso_box?: QSOBox;
|
||||
}
|
||||
|
||||
// RenderModel mirrors qslcard.RenderModel: a template with placeholders
|
||||
// resolved plus the values for the QSO box fields.
|
||||
export interface RenderModel {
|
||||
template: CardTemplate;
|
||||
qso_fields: Record<string, string>;
|
||||
}
|
||||
|
||||
// Binding result types (see app_qsl_designer.go).
|
||||
export interface QSLTemplateInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
profile_id?: number;
|
||||
is_default: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface QSLFontInfo {
|
||||
family: string;
|
||||
kind: 'display' | 'script' | 'system';
|
||||
variable: boolean;
|
||||
data_b64: string;
|
||||
}
|
||||
|
||||
export interface QSLPresetInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
params: string[];
|
||||
defaults: StyleParams;
|
||||
}
|
||||
|
||||
// Human labels for the QSO box fields the renderer knows.
|
||||
export const QSO_FIELD_LABELS: Record<string, string> = {
|
||||
qso_date: 'Date',
|
||||
time_on: 'UTC',
|
||||
band: 'Band',
|
||||
mode: 'Mode',
|
||||
rst_sent: 'RST',
|
||||
freq: 'Freq',
|
||||
submode: 'Submode',
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// Rasterizes the CardPreview SVG to bitmap bytes. The SVG is serialized and
|
||||
// loaded through an <img>, so every resource it needs (fonts, photos, flag)
|
||||
// must be inline data URLs — CardPreview guarantees that. Fonts are awaited
|
||||
// before drawing so glyphs never rasterize with a fallback face.
|
||||
|
||||
// rasterizeCard returns base64 image bytes (no data: prefix).
|
||||
// type 'image/jpeg' targets e-mail (maxBytes enforced by stepping quality
|
||||
// down); 'image/png' is used for template thumbnails.
|
||||
export async function rasterizeCard(
|
||||
svgEl: SVGSVGElement,
|
||||
outW: number,
|
||||
outH: number,
|
||||
type: 'image/jpeg' | 'image/png',
|
||||
maxBytes = 800 * 1024,
|
||||
): Promise<string> {
|
||||
await document.fonts.ready;
|
||||
|
||||
const clone = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
clone.setAttribute('width', String(outW));
|
||||
clone.setAttribute('height', String(outH));
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
await img.decode();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas 2d context unavailable');
|
||||
if (type === 'image/jpeg') {
|
||||
ctx.fillStyle = '#ffffff'; // JPEG has no alpha — avoid black background
|
||||
ctx.fillRect(0, 0, outW, outH);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, outW, outH);
|
||||
|
||||
if (type === 'image/png') {
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
// Step the JPEG quality down until the e-mail size target is met.
|
||||
for (const q of [0.9, 0.8, 0.7, 0.6]) {
|
||||
const b64 = canvas.toDataURL('image/jpeg', q).split(',')[1];
|
||||
if (b64.length * 0.75 <= maxBytes) return b64;
|
||||
}
|
||||
return canvas.toDataURL('image/jpeg', 0.5).split(',')[1];
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
// Canvas-2D call-text renderer, ported faithfully from the user's two
|
||||
// reference generators ("glossy bulle" / XV9Q and "western 3D" / HS0ZLE).
|
||||
// SVG filters can only approximate these looks; the exact technique is
|
||||
// canvas — inflated glyphs (fill + stroke to dilate), contour bands via
|
||||
// boolean compositing, a candy gloss that hugs the outline, an orange inner
|
||||
// shadow, and organic multi-layer grunge.
|
||||
//
|
||||
// renderTextFx() runs the recipe offscreen, trims the transparent padding,
|
||||
// and returns a PNG data URL + its size in logical (card) pixels. CardPreview
|
||||
// embeds that as an <image> so the same output drives the editor preview,
|
||||
// thumbnails and the final e-mail rasterization.
|
||||
|
||||
export type TextFxKind = 'glossy' | 'western';
|
||||
|
||||
export interface TextFxParams {
|
||||
kind: TextFxKind;
|
||||
text: string;
|
||||
weight: string; // canvas font weight, e.g. "800" or "400"
|
||||
family: string; // canvas font family, e.g. '"Baloo 2", sans-serif'
|
||||
size: number; // font size in card px
|
||||
space: number; // letter spacing in px
|
||||
cTop: string;
|
||||
cMid: string;
|
||||
cBot: string;
|
||||
cDark: string; // dark edge / outline
|
||||
cOuter?: string; // silver rim (glossy only)
|
||||
// glossy
|
||||
plump?: number; // 0..0.06 — inflation as a fraction of size
|
||||
edge?: number; // 0..1 — dark edge weight
|
||||
outerw?: number; // 0..1 — silver rim weight
|
||||
gloss?: number; // 0..1 — gloss intensity
|
||||
glossH?: number; // 0..1 — gloss height
|
||||
glossI?: number; // 0..0.14 — gloss inset
|
||||
innerB?: number; // 0..1 — bottom inner shadow
|
||||
// western
|
||||
depth?: number; // 3D extrusion depth in px
|
||||
angle?: number; // extrusion direction in radians
|
||||
slant?: number; // shear, -0.4..0.4
|
||||
grunge?: number; // 0..1 — distress amount
|
||||
bevel?: number; // 0..1 — top-edge light
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface TextFxResult {
|
||||
url: string; // PNG data URL of the trimmed text
|
||||
w: number; // logical (card-px) width
|
||||
h: number; // logical (card-px) height
|
||||
}
|
||||
|
||||
const SS = 2; // supersample for crisp downscaling
|
||||
|
||||
function supportsFilter(c: CanvasRenderingContext2D) {
|
||||
return typeof c.filter === 'string';
|
||||
}
|
||||
function shade(hex: string, pct: number) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v + (255 * pct) / 100)));
|
||||
return 'rgb(' + f(n >> 16) + ',' + f((n >> 8) & 255) + ',' + f(n & 255) + ')';
|
||||
}
|
||||
function deepen(hex: string) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return 'rgb(' + Math.round((n >> 16) * 0.96) + ',' + Math.round(((n >> 8) & 255) * 0.62) + ',' + Math.round((n & 255) * 0.25) + ')';
|
||||
}
|
||||
function rustOf(hex: string) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = Math.min(255, Math.round((n >> 16) * 0.72));
|
||||
const g = Math.round(((n >> 8) & 255) * 0.49);
|
||||
const b = Math.min(255, Math.round((n & 255) * 0.3) + 10);
|
||||
return 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||
}
|
||||
function mulberry32(seed: number) {
|
||||
return function () {
|
||||
seed |= 0;
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function hashStr(s: string) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// ── glossy bubble (XV9Q) ────────────────────────────────────────────────
|
||||
function renderGlossy(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'plump' | 'edge' | 'outerw' | 'gloss' | 'glossH' | 'glossI' | 'innerB' | 'cTop' | 'cMid' | 'cBot' | 'cDark' | 'cOuter'>>, scale: number): HTMLCanvasElement {
|
||||
const probe = document.createElement('canvas').getContext('2d')!;
|
||||
const font = p.weight + ' ' + p.size + 'px ' + p.family;
|
||||
probe.letterSpacing = p.space + 'px';
|
||||
probe.font = font;
|
||||
const m = probe.measureText(p.text);
|
||||
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
|
||||
const capH = asc + desc;
|
||||
const plumpPx = p.size * p.plump;
|
||||
const edgeW = p.size * 0.1 * p.edge;
|
||||
const outerW = edgeW + p.size * 0.22 * p.outerw + 2 * plumpPx;
|
||||
const pad = Math.ceil(outerW / 2 + p.size * 0.12);
|
||||
const w = Math.ceil(m.width + pad * 2);
|
||||
const h = Math.ceil(capH + pad * 2);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w * scale;
|
||||
canvas.height = h * scale;
|
||||
const c = canvas.getContext('2d')!;
|
||||
|
||||
const x = pad, y = pad + asc;
|
||||
const top = y - asc - plumpPx, bot = y + desc + plumpPx;
|
||||
|
||||
const mk = () => {
|
||||
const k = document.createElement('canvas');
|
||||
k.width = canvas.width;
|
||||
k.height = canvas.height;
|
||||
return k;
|
||||
};
|
||||
const setT = (cx: CanvasRenderingContext2D) => {
|
||||
cx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
cx.letterSpacing = p.space + 'px';
|
||||
cx.font = font;
|
||||
cx.textBaseline = 'alphabetic';
|
||||
cx.lineJoin = 'round';
|
||||
cx.lineCap = 'round';
|
||||
};
|
||||
const glyph = (delta: number, dx?: number, dy?: number) => {
|
||||
const k = mk();
|
||||
const g = k.getContext('2d')!;
|
||||
setT(g);
|
||||
g.fillStyle = '#fff';
|
||||
g.strokeStyle = '#fff';
|
||||
g.fillText(p.text, x + (dx || 0), y + (dy || 0));
|
||||
if (delta > 0.1) {
|
||||
g.lineWidth = delta * 2;
|
||||
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
|
||||
} else if (delta < -0.1) {
|
||||
g.globalCompositeOperation = 'destination-out';
|
||||
g.lineWidth = -delta * 2;
|
||||
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
|
||||
}
|
||||
return k;
|
||||
};
|
||||
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
|
||||
const g = k.getContext('2d')!;
|
||||
g.save();
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
g.globalCompositeOperation = 'destination-in';
|
||||
g.drawImage(mask, 0, 0);
|
||||
g.restore();
|
||||
return k;
|
||||
};
|
||||
const contourBand = (shiftX: number, shiftY: number, insetPx: number) => {
|
||||
const k = glyph(plumpPx, 0, 0);
|
||||
const g = k.getContext('2d')!;
|
||||
g.save();
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
g.globalCompositeOperation = 'destination-out';
|
||||
g.drawImage(glyph(plumpPx, shiftX, shiftY), 0, 0);
|
||||
g.restore();
|
||||
return clipTo(k, glyph(plumpPx - insetPx, 0, 0));
|
||||
};
|
||||
const vRamp = (k: HTMLCanvasElement, f0: number, f1: number, keepTop: boolean) => {
|
||||
const g = k.getContext('2d')!;
|
||||
g.save();
|
||||
g.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
g.globalCompositeOperation = 'destination-in';
|
||||
const gr = g.createLinearGradient(0, top, 0, bot);
|
||||
const a1 = keepTop ? 1 : 0, a2 = keepTop ? 0 : 1;
|
||||
gr.addColorStop(0, 'rgba(255,255,255,' + a1 + ')');
|
||||
gr.addColorStop(Math.max(0.001, Math.min(0.998, f0)), 'rgba(255,255,255,' + a1 + ')');
|
||||
gr.addColorStop(Math.max(0.002, Math.min(0.999, f1)), 'rgba(255,255,255,' + a2 + ')');
|
||||
gr.addColorStop(1, 'rgba(255,255,255,' + a2 + ')');
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, 0, k.width / scale, k.height / scale);
|
||||
g.restore();
|
||||
return k;
|
||||
};
|
||||
const blurred = (src: HTMLCanvasElement, px: number) => {
|
||||
const k = mk();
|
||||
const g = k.getContext('2d')!;
|
||||
if (supportsFilter(g) && px > 0.3) {
|
||||
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
|
||||
g.drawImage(src, 0, 0);
|
||||
g.filter = 'none';
|
||||
} else {
|
||||
g.drawImage(src, 0, 0);
|
||||
}
|
||||
return k;
|
||||
};
|
||||
const tint = (k: HTMLCanvasElement, color: string, alpha: number) => {
|
||||
const g = k.getContext('2d')!;
|
||||
g.save();
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
g.globalCompositeOperation = 'source-in';
|
||||
g.fillStyle = color;
|
||||
g.fillRect(0, 0, k.width, k.height);
|
||||
g.restore();
|
||||
if (alpha < 1) {
|
||||
const k2 = mk();
|
||||
const g2 = k2.getContext('2d')!;
|
||||
g2.globalAlpha = alpha;
|
||||
g2.drawImage(k, 0, 0);
|
||||
return k2;
|
||||
}
|
||||
return k;
|
||||
};
|
||||
const paintText = (cx: CanvasRenderingContext2D, paint: string | CanvasGradient) => {
|
||||
cx.fillStyle = paint;
|
||||
cx.fillText(p.text, x, y);
|
||||
if (plumpPx > 0.1) {
|
||||
cx.strokeStyle = paint;
|
||||
cx.lineWidth = plumpPx * 2;
|
||||
cx.strokeText(p.text, x, y);
|
||||
}
|
||||
};
|
||||
|
||||
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||
c.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setT(c);
|
||||
|
||||
// drop shadow
|
||||
c.save();
|
||||
c.shadowColor = 'rgba(10,15,25,0.40)';
|
||||
c.shadowBlur = p.size * 0.045 * scale;
|
||||
c.shadowOffsetY = p.size * 0.03 * scale;
|
||||
c.lineWidth = Math.max(outerW, edgeW + 2 * plumpPx, 1);
|
||||
c.strokeStyle = 'rgba(10,15,25,0.40)';
|
||||
c.strokeText(p.text, x, y);
|
||||
c.restore();
|
||||
|
||||
const face = mk();
|
||||
const f = face.getContext('2d')!;
|
||||
setT(f);
|
||||
|
||||
if (p.outerw > 0) {
|
||||
const gOuter = f.createLinearGradient(0, top - outerW / 2, 0, bot + outerW / 2);
|
||||
gOuter.addColorStop(0, '#ffffff');
|
||||
gOuter.addColorStop(0.45, p.cOuter);
|
||||
gOuter.addColorStop(1, shade(p.cOuter, -22));
|
||||
f.lineWidth = outerW;
|
||||
f.strokeStyle = gOuter;
|
||||
f.strokeText(p.text, x, y);
|
||||
}
|
||||
if (p.edge > 0) {
|
||||
f.lineWidth = edgeW + 2 * plumpPx;
|
||||
f.strokeStyle = p.cDark;
|
||||
f.strokeText(p.text, x, y);
|
||||
}
|
||||
const gBody = f.createLinearGradient(0, top, 0, bot);
|
||||
gBody.addColorStop(0, p.cTop);
|
||||
gBody.addColorStop(0.55, p.cMid);
|
||||
gBody.addColorStop(1, p.cBot);
|
||||
paintText(f, gBody);
|
||||
|
||||
if (p.innerB > 0) {
|
||||
let band = contourBand(0, -0.22 * capH, p.size * 0.03);
|
||||
band = blurred(band, p.size * 0.04);
|
||||
band = clipTo(band, glyph(plumpPx, 0, 0));
|
||||
band = vRamp(band, 0.45, 0.8, false);
|
||||
band = tint(band, deepen(p.cBot), 0.7);
|
||||
f.save();
|
||||
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||
f.globalCompositeOperation = 'source-atop';
|
||||
f.globalAlpha = Math.min(1, p.innerB);
|
||||
f.drawImage(band, 0, 0);
|
||||
f.restore();
|
||||
}
|
||||
|
||||
if (p.gloss > 0) {
|
||||
const shiftY = p.glossH * capH;
|
||||
const shiftX = 0.12 * shiftY;
|
||||
const inset = p.size * p.glossI;
|
||||
const clip0 = p.glossH * 0.6, clip1 = Math.min(0.95, p.glossH * 1.4);
|
||||
let band = contourBand(shiftX, shiftY, inset);
|
||||
band = vRamp(band, clip0, clip1, true);
|
||||
let halo = blurred(band, p.size * 0.014);
|
||||
halo = clipTo(halo, glyph(plumpPx - inset * 0.5, 0, 0));
|
||||
let core = mk();
|
||||
core.getContext('2d')!.drawImage(band, 0, 0);
|
||||
core = clipTo(core, glyph(plumpPx - inset * 1.35, 0, 0));
|
||||
core = blurred(core, p.size * 0.007);
|
||||
f.save();
|
||||
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||
f.globalCompositeOperation = 'source-atop';
|
||||
f.globalAlpha = Math.min(1, 0.85 * p.gloss);
|
||||
f.drawImage(halo, 0, 0);
|
||||
f.globalAlpha = Math.min(1, 1.15 * p.gloss);
|
||||
f.drawImage(core, 0, 0);
|
||||
f.restore();
|
||||
}
|
||||
|
||||
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||
c.drawImage(face, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// ── western 3D (HS0ZLE) ─────────────────────────────────────────────────
|
||||
function noiseMask(W: number, H: number, featurePx: number, thresh: number, seed: number, softenPx: number, stretchY = 1): HTMLCanvasElement {
|
||||
const sw = Math.max(2, Math.round(W / featurePx));
|
||||
const sh = Math.max(2, Math.round(H / (featurePx * stretchY)));
|
||||
const small = document.createElement('canvas');
|
||||
small.width = sw;
|
||||
small.height = sh;
|
||||
const sg = small.getContext('2d')!;
|
||||
const id = sg.createImageData(sw, sh);
|
||||
const rnd = mulberry32(seed);
|
||||
for (let i = 0; i < sw * sh; i++) {
|
||||
const v = (rnd() * 255) | 0;
|
||||
id.data[i * 4] = v;
|
||||
id.data[i * 4 + 1] = v;
|
||||
id.data[i * 4 + 2] = v;
|
||||
id.data[i * 4 + 3] = 255;
|
||||
}
|
||||
sg.putImageData(id, 0, 0);
|
||||
const big = document.createElement('canvas');
|
||||
big.width = W;
|
||||
big.height = H;
|
||||
const bg = big.getContext('2d')!;
|
||||
bg.imageSmoothingEnabled = true;
|
||||
bg.imageSmoothingQuality = 'high';
|
||||
if (supportsFilter(bg)) bg.filter = 'blur(' + (featurePx * 0.35).toFixed(1) + 'px)';
|
||||
bg.drawImage(small, 0, 0, W, H);
|
||||
if (supportsFilter(bg)) bg.filter = 'none';
|
||||
const src = bg.getImageData(0, 0, W, H);
|
||||
const out = bg.createImageData(W, H);
|
||||
const k = 1 / Math.max(1e-6, (1 - thresh) * 0.35);
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
const v = src.data[i * 4] / 255;
|
||||
let a = (v - thresh) * k;
|
||||
a = a < 0 ? 0 : a > 1 ? 1 : a;
|
||||
out.data[i * 4] = 255;
|
||||
out.data[i * 4 + 1] = 255;
|
||||
out.data[i * 4 + 2] = 255;
|
||||
out.data[i * 4 + 3] = (a * 255) | 0;
|
||||
}
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = W;
|
||||
mask.height = H;
|
||||
const mg = mask.getContext('2d')!;
|
||||
if (supportsFilter(mg) && softenPx > 0.3) mg.filter = 'blur(' + softenPx.toFixed(1) + 'px)';
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = W;
|
||||
tmp.height = H;
|
||||
tmp.getContext('2d')!.putImageData(out, 0, 0);
|
||||
mg.drawImage(tmp, 0, 0);
|
||||
if (supportsFilter(mg)) mg.filter = 'none';
|
||||
return mask;
|
||||
}
|
||||
|
||||
function renderWestern(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'depth' | 'angle' | 'slant' | 'grunge' | 'bevel' | 'seed' | 'cTop' | 'cMid' | 'cBot' | 'cDark'>>, scale: number): HTMLCanvasElement {
|
||||
const probe = document.createElement('canvas').getContext('2d')!;
|
||||
const font = p.weight + ' ' + p.size + 'px ' + p.family;
|
||||
probe.letterSpacing = p.space + 'px';
|
||||
probe.font = font;
|
||||
const m = probe.measureText(p.text);
|
||||
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
|
||||
const capH = asc + desc;
|
||||
const edgeW = p.size * 0.07;
|
||||
const dx = Math.cos(p.angle) * p.depth, dy = Math.sin(p.angle) * p.depth;
|
||||
const slantPad = Math.abs(p.slant) * capH;
|
||||
const pad = Math.ceil(edgeW + p.size * 0.1 + p.depth + slantPad);
|
||||
const w = Math.ceil(m.width + pad * 2);
|
||||
const h = Math.ceil(capH + pad * 2);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w * scale;
|
||||
canvas.height = h * scale;
|
||||
const c = canvas.getContext('2d')!;
|
||||
|
||||
const x = pad - Math.min(0, dx), y = pad + asc - Math.min(0, dy);
|
||||
const top = y - asc, bot = y + desc;
|
||||
|
||||
const mk = () => {
|
||||
const k = document.createElement('canvas');
|
||||
k.width = canvas.width;
|
||||
k.height = canvas.height;
|
||||
return k;
|
||||
};
|
||||
const setT = (cx: CanvasRenderingContext2D) => {
|
||||
cx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
cx.transform(1, 0, -p.slant, 1, p.slant * y, 0);
|
||||
cx.letterSpacing = p.space + 'px';
|
||||
cx.font = font;
|
||||
cx.textBaseline = 'alphabetic';
|
||||
cx.lineJoin = 'round';
|
||||
cx.lineCap = 'butt'; // their source set 'miter' here, which browsers ignore on lineCap
|
||||
};
|
||||
const glyph = (dxx?: number, dyy?: number) => {
|
||||
const k = mk();
|
||||
const g = k.getContext('2d')!;
|
||||
setT(g);
|
||||
g.fillStyle = '#fff';
|
||||
g.fillText(p.text, x + (dxx || 0), y + (dyy || 0));
|
||||
return k;
|
||||
};
|
||||
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
|
||||
const g = k.getContext('2d')!;
|
||||
g.save();
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
g.globalCompositeOperation = 'destination-in';
|
||||
g.drawImage(mask, 0, 0);
|
||||
g.restore();
|
||||
return k;
|
||||
};
|
||||
const blurred = (src: HTMLCanvasElement, px: number) => {
|
||||
const k = mk();
|
||||
const g = k.getContext('2d')!;
|
||||
if (supportsFilter(g) && px > 0.3) {
|
||||
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
|
||||
g.drawImage(src, 0, 0);
|
||||
g.filter = 'none';
|
||||
} else {
|
||||
g.drawImage(src, 0, 0);
|
||||
}
|
||||
return k;
|
||||
};
|
||||
const tintAlpha = (k: HTMLCanvasElement, color: string, alpha: number) => {
|
||||
const g = k.getContext('2d')!;
|
||||
g.save();
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
g.globalCompositeOperation = 'source-in';
|
||||
g.fillStyle = color;
|
||||
g.fillRect(0, 0, k.width, k.height);
|
||||
g.restore();
|
||||
const k2 = mk();
|
||||
const g2 = k2.getContext('2d')!;
|
||||
g2.globalAlpha = alpha;
|
||||
g2.drawImage(k, 0, 0);
|
||||
return k2;
|
||||
};
|
||||
|
||||
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||
c.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setT(c);
|
||||
|
||||
// drop shadow
|
||||
c.save();
|
||||
c.shadowColor = 'rgba(15,8,4,0.50)';
|
||||
c.shadowBlur = p.size * 0.035 * scale;
|
||||
c.shadowOffsetX = p.size * 0.02 * scale;
|
||||
c.shadowOffsetY = p.size * 0.035 * scale;
|
||||
c.lineWidth = edgeW;
|
||||
c.strokeStyle = 'rgba(15,8,4,0.50)';
|
||||
c.fillStyle = 'rgba(15,8,4,0.50)';
|
||||
c.strokeText(p.text, x, y);
|
||||
c.fillText(p.text, x, y);
|
||||
c.restore();
|
||||
|
||||
// 3D extrusion
|
||||
if (p.depth > 0) {
|
||||
const deepCol = shade(p.cDark, -4);
|
||||
c.lineWidth = edgeW;
|
||||
const steps = Math.ceil(p.depth);
|
||||
for (let i = steps; i >= 1; i--) {
|
||||
const t = i / steps;
|
||||
c.strokeStyle = deepCol;
|
||||
c.fillStyle = deepCol;
|
||||
c.strokeText(p.text, x + dx * t, y + dy * t);
|
||||
c.fillText(p.text, x + dx * t, y + dy * t);
|
||||
}
|
||||
}
|
||||
|
||||
const face = mk();
|
||||
const f = face.getContext('2d')!;
|
||||
setT(f);
|
||||
|
||||
f.lineWidth = edgeW;
|
||||
f.strokeStyle = p.cDark;
|
||||
f.strokeText(p.text, x, y);
|
||||
|
||||
const gBody = f.createLinearGradient(0, top, 0, bot);
|
||||
gBody.addColorStop(0, p.cTop);
|
||||
gBody.addColorStop(0.5, p.cMid);
|
||||
gBody.addColorStop(1, p.cBot);
|
||||
f.fillStyle = gBody;
|
||||
f.fillText(p.text, x, y);
|
||||
|
||||
const M = glyph(0, 0);
|
||||
|
||||
if (p.bevel > 0) {
|
||||
let band = glyph(0, 0);
|
||||
const g = band.getContext('2d')!;
|
||||
g.save();
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
g.globalCompositeOperation = 'destination-out';
|
||||
g.drawImage(glyph(0.02 * p.size, 0.06 * capH), 0, 0);
|
||||
g.restore();
|
||||
band = blurred(band, p.size * 0.008);
|
||||
band = clipTo(band, M);
|
||||
band = tintAlpha(band, '#ffeba8', p.bevel);
|
||||
f.save();
|
||||
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||
f.globalCompositeOperation = 'source-atop';
|
||||
f.drawImage(band, 0, 0);
|
||||
f.restore();
|
||||
}
|
||||
|
||||
if (p.grunge > 0) {
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const S = p.size * scale;
|
||||
const seed = (hashStr(p.text + '|' + p.family) ^ (p.seed * 2654435761)) >>> 0;
|
||||
const rust = rustOf(p.cBot);
|
||||
const layers = [
|
||||
{ mask: noiseMask(W, H, 0.022 * S, 0.72, seed, 0.6 * scale), color: rust, a: 0.8 },
|
||||
{ mask: noiseMask(W, H, 0.011 * S, 0.7, seed + 11, 0.5 * scale, 4.0), color: rust, a: 0.6 },
|
||||
{ mask: noiseMask(W, H, 0.0066 * S, 0.74, seed + 7, 0.3 * scale), color: rust, a: 0.75 },
|
||||
{ mask: noiseMask(W, H, 0.0154 * S, 0.86, seed + 3, 0.4 * scale), color: p.cDark, a: 0.9 },
|
||||
];
|
||||
f.save();
|
||||
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||
f.globalCompositeOperation = 'source-atop';
|
||||
for (const L of layers) {
|
||||
let k = clipTo(L.mask, M);
|
||||
k = tintAlpha(k, L.color, L.a * p.grunge);
|
||||
f.drawImage(k, 0, 0);
|
||||
}
|
||||
f.restore();
|
||||
}
|
||||
|
||||
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||
c.drawImage(face, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Trim transparent padding so the element's x/y maps to the visible glyphs.
|
||||
function trim(canvas: HTMLCanvasElement): { canvas: HTMLCanvasElement; w: number; h: number } {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const data = ctx.getImageData(0, 0, W, H).data;
|
||||
let minX = W, minY = H, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (data[(y * W + x) * 4 + 3] > 8) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < minX) return { canvas, w: W, h: H };
|
||||
const cw = maxX - minX + 1, ch = maxY - minY + 1;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = cw;
|
||||
out.height = ch;
|
||||
out.getContext('2d')!.drawImage(canvas, minX, minY, cw, ch, 0, 0, cw, ch);
|
||||
return { canvas: out, w: cw, h: ch };
|
||||
}
|
||||
|
||||
export function renderTextFx(p: TextFxParams): TextFxResult {
|
||||
let raw: HTMLCanvasElement;
|
||||
if (p.kind === 'western') {
|
||||
raw = renderWestern(
|
||||
{
|
||||
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
|
||||
depth: p.depth ?? p.size * 0.06, angle: p.angle ?? (112 * Math.PI) / 180,
|
||||
slant: p.slant ?? 0.08, grunge: p.grunge ?? 0.8, bevel: p.bevel ?? 0.45, seed: p.seed ?? 7,
|
||||
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark,
|
||||
},
|
||||
SS,
|
||||
);
|
||||
} else {
|
||||
raw = renderGlossy(
|
||||
{
|
||||
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
|
||||
plump: p.plump ?? 0.03, edge: p.edge ?? 0.5, outerw: p.outerw ?? 0.45,
|
||||
gloss: p.gloss ?? 0.85, glossH: p.glossH ?? 0.45, glossI: p.glossI ?? 0.08, innerB: p.innerB ?? 0.7,
|
||||
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark, cOuter: p.cOuter ?? '#ced3db',
|
||||
},
|
||||
SS,
|
||||
);
|
||||
}
|
||||
const t = trim(raw);
|
||||
return { url: t.canvas.toDataURL('image/png'), w: t.w / SS, h: t.h / SS };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Input } from './input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Searchable combobox: type to filter, click/Enter to pick. On blur it commits
|
||||
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
||||
// can't hold a typo'd value that isn't in the list.
|
||||
export function Combobox({
|
||||
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
allowFreeText?: boolean;
|
||||
// Commit each keystroke to the parent immediately (not just on blur). Use for
|
||||
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
||||
// leaving the field uses the value just typed.
|
||||
commitOnType?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, []);
|
||||
|
||||
const filtered = open
|
||||
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
|
||||
: [];
|
||||
|
||||
function commit(v: string) {
|
||||
onChange(v);
|
||||
setQuery(v);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// Defer so a click on an option registers first.
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
const trimmed = query.trim();
|
||||
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
|
||||
// Only fire onChange when the value actually changed — committing an
|
||||
// unchanged value on a plain tab-through would wrongly flag the field as
|
||||
// "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
|
||||
if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
|
||||
else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
|
||||
else { setQuery(value); } // revert typo
|
||||
}, 120);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative', className)}>
|
||||
<Input
|
||||
value={open ? query : value}
|
||||
placeholder={placeholder}
|
||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||
// open the list (so tabbing in doesn't pop the dropdown).
|
||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
|
||||
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
|
||||
// Tab: just let it move on; onBlur commits/closes. Options are
|
||||
// tabIndex=-1 so a single Tab leaves the field.
|
||||
}}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="block w-full text-left px-2 py-1 hover:bg-accent/40"
|
||||
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
|
||||
>(({ className, children, hideClose, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
|
||||
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
{!hideOverlay && <DialogOverlay />}
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -14,7 +14,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-stone-900 px-2.5 py-1.5 text-xs text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Shared helpers for per-QSO award references.
|
||||
//
|
||||
// In the UI a QSO's manually-assigned award references are edited as a single
|
||||
// semicolon-delimited string of "CODE@REF" entries, e.g.
|
||||
// "POTA@FR-11553;IOTA@EU-064"
|
||||
// On save each entry is routed to the QSO field its award actually reads from
|
||||
// (see internal/award/award.go): POTA/SOTA/IOTA have dedicated columns; WWFF
|
||||
// and custom awards live in uppercase ADIF extras keys.
|
||||
|
||||
// parseAwardRefs turns "POTA@FR-11553;IOTA@EU-064" into
|
||||
// { POTA: "FR-11553", IOTA: "EU-064" }. Repeated codes join with commas.
|
||||
export function parseAwardRefs(v: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const entry of (v ?? '').split(';').filter(Boolean)) {
|
||||
const at = entry.indexOf('@');
|
||||
if (at <= 0) continue;
|
||||
const code = entry.slice(0, at).toUpperCase();
|
||||
const ref = entry.slice(at + 1).trim().toUpperCase();
|
||||
if (!ref) continue;
|
||||
out[code] = out[code] ? `${out[code]},${ref}` : ref;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// appendTokens adds space-separated tokens (a "A,B" ref string) to a text field,
|
||||
// skipping any already present, so re-picking is idempotent.
|
||||
function appendTokens(existing: string | undefined, refs: string): string {
|
||||
let out = (existing ?? '').trim();
|
||||
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
const re = new RegExp(`(^|\\s)${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i');
|
||||
if (!re.test(out)) out = out ? `${out} ${tok}` : tok;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
|
||||
// text field — the inverse of appendTokens. Used when a picked reference is
|
||||
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
|
||||
// otherwise the ref is re-derived from the field and reappears on reopen.
|
||||
function removeTokens(existing: string | undefined, refs: string): string {
|
||||
let out = existing ?? '';
|
||||
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
return out.replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||
// The award engine honours these regardless of how the award matches, so a
|
||||
// reference can be assigned even for awards that scan a free-text field by
|
||||
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
|
||||
// the field wouldn't match.
|
||||
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
||||
|
||||
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
|
||||
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
|
||||
// awards backed by a standard ADIF column also get that column written so the
|
||||
// data lives in its conventional place and exports correctly. Awards that match
|
||||
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||
// on the override — we don't pollute the text field with a code.
|
||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
|
||||
const byCode = parseAwardRefs(awardRefs);
|
||||
// References the operator REMOVED since the editor opened: for in-field awards
|
||||
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
|
||||
// ref doesn't get re-derived from the leftover token and reappear on reopen.
|
||||
if (prevAwardRefs != null) {
|
||||
const prev = parseAwardRefs(prevAwardRefs);
|
||||
for (const [code, prevRef] of Object.entries(prev)) {
|
||||
const field = fieldOf[code] || code.toLowerCase();
|
||||
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
|
||||
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
|
||||
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
|
||||
if (removed.length === 0) continue;
|
||||
const rm = removed.join(',');
|
||||
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
|
||||
else payload.notes = removeTokens(payload.notes, rm);
|
||||
}
|
||||
}
|
||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||
const overrides: string[] = [];
|
||||
for (const [code, ref] of Object.entries(byCode)) {
|
||||
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
overrides.push(`${code}@${r}`);
|
||||
}
|
||||
const field = fieldOf[code] || code.toLowerCase();
|
||||
switch (field) {
|
||||
case 'iota': payload.iota = ref; break;
|
||||
case 'sota_ref': payload.sota_ref = ref; break;
|
||||
case 'pota_ref': payload.pota_ref = ref; break;
|
||||
// Predefined-list awards on a QSO field (WAS/RAC/WAJA on state, JCC on
|
||||
// county): picking a reference writes it straight into that column.
|
||||
case 'state': payload.state = ref; break;
|
||||
case 'cnty': payload.cnty = ref; break;
|
||||
case 'wwff':
|
||||
extras['WWFF_REF'] = ref;
|
||||
extras['SIG'] = 'WWFF';
|
||||
extras['SIG_INFO'] = ref;
|
||||
break;
|
||||
// QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
|
||||
// "D06" in the note): append the code so the in-field matcher finds it too.
|
||||
case 'note': case 'notes':
|
||||
payload.notes = appendTokens(payload.notes, ref);
|
||||
break;
|
||||
case 'comment':
|
||||
payload.comment = appendTokens(payload.comment, ref);
|
||||
break;
|
||||
// address / qth / name / custom: the override above is authoritative; do
|
||||
// not write a code into a free-text field the award matches by name.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
|
||||
else delete extras[MANUAL_REFS_KEY];
|
||||
if (Object.keys(extras).length > 0) payload.extras = extras;
|
||||
else if (payload.extras) delete payload.extras;
|
||||
}
|
||||
|
||||
// awardRefValue reads a single award's stored reference from a QSO, inverse of
|
||||
// applyAwardRefs. Used to seed the editor when opening an existing QSO.
|
||||
export function awardRefValue(qso: any, code: string, field: string): string {
|
||||
switch (field) {
|
||||
case 'iota': return (qso.iota ?? '').toUpperCase();
|
||||
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
|
||||
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
|
||||
case 'state': return (qso.state ?? '').toUpperCase();
|
||||
case 'cnty': return (qso.cnty ?? '').toUpperCase();
|
||||
case 'wwff': {
|
||||
const ex = qso.extras ?? {};
|
||||
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
|
||||
if (String(ex['SIG'] ?? '').toUpperCase() === 'WWFF') return String(ex['SIG_INFO'] ?? '').toUpperCase();
|
||||
return '';
|
||||
}
|
||||
default: {
|
||||
const ex = qso.extras ?? {};
|
||||
return String(ex[field.toUpperCase()] ?? '').toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildAwardRefs reconstructs the "CODE@REF;…" editor string from a QSO for the
|
||||
// given pickable awards (code → field). Only awards with a stored value appear.
|
||||
export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field: string }>): string {
|
||||
const out: string[] = [];
|
||||
for (const { code, field } of pickable) {
|
||||
const v = awardRefValue(qso, code, field);
|
||||
// A multi-reference field (n-fer POTA "US-6544,US-0680") becomes one
|
||||
// editor entry per reference, so each shows on its own removable line.
|
||||
for (const ref of v.split(/[,;]/).map((s) => s.trim()).filter(Boolean)) {
|
||||
out.push(`${code.toUpperCase()}@${ref}`);
|
||||
}
|
||||
}
|
||||
return out.join(';');
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Maps ADIF DXCC entity numbers to ISO-3166 alpha-2 codes (flagcdn slugs),
|
||||
// so we can show the contacted entity's flag in the UI.
|
||||
//
|
||||
// Why not emoji flags? Windows does NOT render regional-indicator flag
|
||||
// emoji (it shows the two letters instead), and this is a Windows app — so
|
||||
// we use flagcdn.com PNGs keyed by ISO code instead.
|
||||
//
|
||||
// Numbers are taken verbatim from internal/dxcc/adif_numbers.go so they're
|
||||
// guaranteed correct. Coverage is the commonly-worked entities; an unknown
|
||||
// DXCC number simply yields no flag (graceful — better than a wrong one).
|
||||
//
|
||||
// French overseas territories (Corsica, Guadeloupe, Martinique, Réunion,
|
||||
// New Caledonia, French Polynesia…) are mapped to "fr": they fly the French
|
||||
// tricolore and several of their ISO codes aren't served by flagcdn.
|
||||
const DXCC_ISO: Record<number, string> = {
|
||||
// North America
|
||||
1: 'ca', 291: 'us', 6: 'us', 110: 'us', 50: 'mx',
|
||||
202: 'pr', 285: 'vi', 91: 'vg', 69: 'ky', 82: 'jm', 60: 'bs', 64: 'bm',
|
||||
78: 'ht', 72: 'do', 70: 'cu', 62: 'bb', 90: 'tt', 77: 'gd', 97: 'lc',
|
||||
98: 'vc', 95: 'dm', 96: 'ms', 249: 'kn', 94: 'ag', 89: 'tc',
|
||||
79: 'fr', 84: 'fr', 63: 'fr',
|
||||
// Central America
|
||||
66: 'bz', 80: 'hn', 74: 'sv', 86: 'ni', 308: 'cr', 88: 'pa', 76: 'gt',
|
||||
// South America
|
||||
116: 'co', 120: 'ec', 71: 'ec', 136: 'pe', 104: 'bo', 112: 'cl',
|
||||
100: 'ar', 144: 'uy', 132: 'py', 108: 'br', 148: 've', 129: 'gy',
|
||||
140: 'sr', 216: 'co',
|
||||
// Western Europe
|
||||
227: 'fr', 214: 'fr', 230: 'de', 209: 'be', 263: 'nl', 254: 'lu',
|
||||
287: 'ch', 251: 'li', 206: 'at', 248: 'it', 225: 'it', 281: 'es',
|
||||
272: 'pt', 203: 'ad', 278: 'sm', 295: 'va', 260: 'mc',
|
||||
// British Isles
|
||||
223: 'gb-eng', 279: 'gb-sct', 294: 'gb-wls', 265: 'gb-nir', 245: 'ie',
|
||||
114: 'im', 122: 'je', 106: 'gg',
|
||||
// Central / Eastern Europe
|
||||
269: 'pl', 503: 'cz', 504: 'sk', 239: 'hu', 275: 'ro', 212: 'bg',
|
||||
296: 'rs', 514: 'me', 499: 'si', 497: 'hr', 501: 'ba', 502: 'mk',
|
||||
522: 'xk', 7: 'al',
|
||||
// Greece / Mediterranean
|
||||
236: 'gr', 45: 'gr', 40: 'gr', 180: 'gr', 215: 'cy', 257: 'mt', 390: 'tr',
|
||||
// Nordic / Baltic
|
||||
221: 'dk', 222: 'fo', 237: 'gl', 284: 'se', 266: 'no', 224: 'fi',
|
||||
5: 'ax', 242: 'is', 52: 'ee', 145: 'lv', 146: 'lt',
|
||||
// Eastern Europe / Caucasus / Russia
|
||||
27: 'by', 288: 'ua', 179: 'md', 75: 'ge', 14: 'am', 18: 'az',
|
||||
54: 'ru', 15: 'ru', 126: 'ru',
|
||||
// Middle East
|
||||
336: 'il', 342: 'jo', 354: 'lb', 384: 'sy', 378: 'sa', 391: 'ae',
|
||||
304: 'bh', 348: 'kw', 376: 'qa', 370: 'om', 492: 'ye', 330: 'ir', 333: 'iq',
|
||||
// North Africa
|
||||
478: 'eg', 436: 'ly', 400: 'dz', 446: 'ma', 302: 'eh', 474: 'tn',
|
||||
// Sub-Saharan Africa
|
||||
462: 'za', 464: 'na', 402: 'bw', 452: 'zw', 482: 'zm', 181: 'mz',
|
||||
438: 'mg', 165: 'mu', 453: 'fr', 379: 'sc', 430: 'ke', 470: 'tz',
|
||||
286: 'ug', 53: 'et', 51: 'er', 466: 'sd', 521: 'ss', 450: 'ng',
|
||||
424: 'gh', 406: 'cm', 456: 'sn', 434: 'lr', 458: 'sl', 416: 'bj',
|
||||
483: 'tg', 428: 'ci', 442: 'ml', 187: 'ne', 410: 'td', 32: 'cv',
|
||||
420: 'ga', 444: 'mr', 382: 'dj', 454: 'rw', 107: 'gn', 39: 'km', 169: 'fr',
|
||||
// Asia
|
||||
339: 'jp', 137: 'kr', 344: 'kp', 318: 'cn', 324: 'in', 372: 'pk',
|
||||
315: 'lk', 369: 'np', 305: 'bd', 306: 'bt', 309: 'mm', 299: 'my',
|
||||
46: 'my', 381: 'sg', 327: 'id', 375: 'ph', 345: 'bn', 312: 'kh',
|
||||
143: 'la', 387: 'th', 293: 'vn', 130: 'kz', 292: 'uz', 280: 'tm',
|
||||
262: 'tj', 135: 'kg', 3: 'af', 159: 'mv', 363: 'mn', 321: 'hk',
|
||||
386: 'tw', 511: 'tl',
|
||||
// Oceania
|
||||
150: 'au', 163: 'pg', 185: 'sb', 158: 'vu', 176: 'fj', 190: 'ws',
|
||||
170: 'nz', 162: 'fr', 175: 'fr', 160: 'to', 282: 'tv', 301: 'ki',
|
||||
188: 'nu', 168: 'mh', 189: 'nf', 147: 'au',
|
||||
};
|
||||
|
||||
// flagURL returns a flagcdn PNG URL for the given DXCC entity number, or ''
|
||||
// when we don't have a mapping. Height ~20px by default (retina-friendly).
|
||||
export function flagURL(dxcc?: number): string {
|
||||
if (!dxcc) return '';
|
||||
const iso = DXCC_ISO[dxcc];
|
||||
return iso ? `https://flagcdn.com/h20/${iso}.png` : '';
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Portable grid-column preferences (visibility / order / width / sort).
|
||||
//
|
||||
// Stored in the DB settings table (so they travel with the logbook and
|
||||
// survive a reinstall) AND mirrored to the WebView localStorage as a fast,
|
||||
// flicker-free cache. On a fresh machine localStorage is empty, so we fall
|
||||
// back to the DB copy and re-seed the cache.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||
// to apply instantly, before the async DB round-trip).
|
||||
export function loadLocal(key: string): any[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
||||
try {
|
||||
const v = await GetUIPref(key);
|
||||
const parsed = v ? JSON.parse(v) : null;
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
||||
export function saveState(key: string, state: any[]) {
|
||||
const json = JSON.stringify(state);
|
||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||
}
|
||||
|
||||
// seedLocal writes a value into the cache without touching the DB (used after
|
||||
// hydrating the cache from the DB on a fresh machine).
|
||||
export function seedLocal(key: string, state: any[]) {
|
||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { themeQuartz } from 'ag-grid-community';
|
||||
|
||||
// Shared AG-Grid theme for every logbook table (Recent QSOs, Worked-before,
|
||||
// Cluster, Net, QSL Manager). Colours are wired to the app's semantic CSS
|
||||
// variables (see style.css) instead of hardcoded hex, so the grids follow the
|
||||
// active theme (light-warm / dark-warm / graphite / high-contrast) at runtime —
|
||||
// flipping <html data-theme> re-resolves these var() references with no
|
||||
// re-render. Keep this the single source of truth; per-grid tweaks (e.g. row
|
||||
// height) chain a `.withParams({...})` on top.
|
||||
export const hamlogGridTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: 'var(--card)',
|
||||
foregroundColor: 'var(--foreground)',
|
||||
headerBackgroundColor: 'var(--muted)',
|
||||
headerTextColor: 'var(--muted-foreground)',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: 'color-mix(in srgb, var(--muted) 40%, var(--card))',
|
||||
rowHoverColor: 'color-mix(in srgb, var(--accent) 55%, var(--card))',
|
||||
selectedRowBackgroundColor: 'var(--accent)',
|
||||
borderColor: 'var(--border)',
|
||||
rowBorder: { color: 'var(--border)', width: 1 },
|
||||
columnBorder: { color: 'var(--border)', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: 'var(--primary)',
|
||||
iconSize: 12,
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -49,6 +49,44 @@ export function gridToLatLon(grid: string): { lat: number; lon: number } | null
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
// latLonToGrid encodes a lat/lon to a Maidenhead locator (default 6 chars).
|
||||
// Inverse of gridToLatLon. Used to derive a default grid from a cty.dat entity
|
||||
// centroid when no provider grid is available (e.g. a portable/rare DX call) —
|
||||
// a centre-of-entity locator is more useful than an empty field.
|
||||
export function latLonToGrid(lat: number, lon: number, precision = 6): string {
|
||||
let adjLon = Math.min(359.9999, Math.max(0, lon + 180));
|
||||
let adjLat = Math.min(179.9999, Math.max(0, lat + 90));
|
||||
const A = 'A'.charCodeAt(0);
|
||||
const a = 'a'.charCodeAt(0);
|
||||
const fLon = Math.floor(adjLon / 20);
|
||||
const fLat = Math.floor(adjLat / 10);
|
||||
adjLon -= fLon * 20;
|
||||
adjLat -= fLat * 10;
|
||||
const sLon = Math.floor(adjLon / 2);
|
||||
const sLat = Math.floor(adjLat);
|
||||
let grid = String.fromCharCode(A + fLon) + String.fromCharCode(A + fLat) + sLon + sLat;
|
||||
if (precision >= 6) {
|
||||
adjLon -= sLon * 2;
|
||||
adjLat -= sLat;
|
||||
grid += String.fromCharCode(a + Math.min(23, Math.floor(adjLon * 12)))
|
||||
+ String.fromCharCode(a + Math.min(23, Math.floor(adjLat * 24)));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
// gridSquareBounds returns the SW/NE corners of a Maidenhead square so a map
|
||||
// can draw its outline. Half-extents shrink with locator precision.
|
||||
export function gridSquareBounds(grid: string):
|
||||
{ south: number; west: number; north: number; east: number } | null {
|
||||
const c = gridToLatLon(grid);
|
||||
if (!c) return null;
|
||||
const g = grid.trim();
|
||||
let dLon = 1, dLat = 0.5; // 4-char square: 2°×1°
|
||||
if (g.length >= 6) { dLon = 1 / 24; dLat = 0.5 / 24; }
|
||||
if (g.length >= 8) { dLon = 1 / 24 / 10; dLat = 0.5 / 24 / 10; }
|
||||
return { south: c.lat - dLat, north: c.lat + dLat, west: c.lon - dLon, east: c.lon + dLon };
|
||||
}
|
||||
|
||||
// PathInfo describes both short and long great-circle path between two
|
||||
// points. Bearing in degrees from true north (0–360). Distance in km.
|
||||
export interface PathInfo {
|
||||
@@ -64,6 +102,16 @@ export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
|
||||
const a = gridToLatLon(fromGrid);
|
||||
const b = gridToLatLon(toGrid);
|
||||
if (!a || !b) return null;
|
||||
return pathBetweenLatLon(a, b);
|
||||
}
|
||||
|
||||
// pathBetweenLatLon computes the great-circle path between two lat/lon points.
|
||||
// Used as a fallback when a station has a known location (e.g. cty.dat entity
|
||||
// coordinates for Svalbard) but no Maidenhead grid.
|
||||
export function pathBetweenLatLon(
|
||||
a: { lat: number; lon: number },
|
||||
b: { lat: number; lon: number },
|
||||
): PathInfo {
|
||||
const φ1 = toRad(a.lat);
|
||||
const φ2 = toRad(b.lat);
|
||||
const Δλ = toRad(b.lon - a.lon);
|
||||
@@ -88,5 +136,56 @@ export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
|
||||
};
|
||||
}
|
||||
|
||||
// greatCirclePoints returns n+1 [lat, lon] points along the short great-circle
|
||||
// path between two lat/lon points (spherical slerp). Longitudes are unwrapped
|
||||
// to stay continuous (no ±180 jump) so a map polyline draws as one smooth arc.
|
||||
export function greatCirclePoints(
|
||||
lat1: number, lon1: number, lat2: number, lon2: number, n = 96,
|
||||
): [number, number][] {
|
||||
const φ1 = toRad(lat1), λ1 = toRad(lon1);
|
||||
const φ2 = toRad(lat2), λ2 = toRad(lon2);
|
||||
// Angular distance between the two points.
|
||||
const sinΔφ = Math.sin((φ2 - φ1) / 2);
|
||||
const sinΔλ = Math.sin((λ2 - λ1) / 2);
|
||||
const h = sinΔφ * sinΔφ + Math.cos(φ1) * Math.cos(φ2) * sinΔλ * sinΔλ;
|
||||
const d = 2 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
const out: [number, number][] = [];
|
||||
if (d === 0) return [[lat1, lon1]];
|
||||
let prevLon = NaN;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const f = i / n;
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d);
|
||||
const B = Math.sin(f * d) / Math.sin(d);
|
||||
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
||||
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
||||
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
||||
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
|
||||
let lon = toDeg(Math.atan2(y, x));
|
||||
// Unwrap longitude so the polyline never snaps across the whole map.
|
||||
if (!Number.isNaN(prevLon)) {
|
||||
while (lon - prevLon > 180) lon -= 360;
|
||||
while (lon - prevLon < -180) lon += 360;
|
||||
}
|
||||
prevLon = lon;
|
||||
out.push([lat, lon]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
||||
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
||||
|
||||
// destinationPoint returns the lat/lon reached from (lat,lon) by travelling
|
||||
// distanceKm along the great circle at the given initial bearing. Used to draw
|
||||
// the antenna beam lobe (a sector of bearings) on the world map.
|
||||
export function destinationPoint(lat: number, lon: number, bearingDeg: number, distanceKm: number): { lat: number; lon: number } {
|
||||
const delta = distanceKm / EARTH_KM;
|
||||
const theta = toRad(bearingDeg);
|
||||
const phi1 = toRad(lat), lam1 = toRad(lon);
|
||||
const sinPhi2 = Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta);
|
||||
const phi2 = Math.asin(Math.max(-1, Math.min(1, sinPhi2)));
|
||||
const y = Math.sin(theta) * Math.sin(delta) * Math.cos(phi1);
|
||||
const x = Math.cos(delta) - Math.sin(phi1) * sinPhi2;
|
||||
const lam2 = lam1 + Math.atan2(y, x);
|
||||
return { lat: toDeg(phi2), lon: ((toDeg(lam2) + 540) % 360) - 180 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// sMeterRST turns an S-meter reading into the RST report you SEND to the other
|
||||
// station (RST tx / rst_sent) — because your S-meter is how strong THEY are.
|
||||
//
|
||||
// s : S-unit 0-9 (used when below S9)
|
||||
// overDb : dB over S9 (0 when at/below S9)
|
||||
// mode : operating mode — CW/digital get a 3-digit RST, phone a 2-digit RS
|
||||
//
|
||||
// For S9+ the dB is rounded UP to the nearest 5 (a 9+3 reading reports 59+5,
|
||||
// 9+12 reports 59+15, …), matching how operators give strong-signal reports.
|
||||
export function sMeterRST(s: number, overDb: number, mode?: string): string {
|
||||
const cw = /CW|RTTY|PSK|FT[0-9]|JT|JS8|MFSK|OLIVIA|DATA|DIG/i.test(mode || '');
|
||||
const strength = Math.max(1, Math.min(9, Math.round(s)));
|
||||
if (cw) return overDb > 0 ? '599' : `5${strength}9`;
|
||||
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||
return `5${strength}`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { writeUiPref } from './uiPref';
|
||||
|
||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||
// travels with the data/ folder like the language).
|
||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||
|
||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||
];
|
||||
|
||||
export const LS_KEY = 'opslog.theme';
|
||||
const DEFAULT: ThemeChoice = 'light-warm';
|
||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||
|
||||
function systemDark(): boolean {
|
||||
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
|
||||
}
|
||||
|
||||
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
|
||||
function resolve(choice: ThemeChoice): string {
|
||||
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
|
||||
return choice;
|
||||
}
|
||||
|
||||
function readStored(): ThemeChoice {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
|
||||
if (v && ALL.includes(v)) return v;
|
||||
} catch { /* private mode */ }
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
// Stamp the resolved theme onto <html data-theme>.
|
||||
export function applyThemeToDom(choice: ThemeChoice): void {
|
||||
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
|
||||
}
|
||||
|
||||
// Call before the first render (main.tsx) so there is no flash of the default
|
||||
// palette while React boots.
|
||||
export function initTheme(): void { applyThemeToDom(readStored()); }
|
||||
|
||||
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
|
||||
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
|
||||
export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||
|
||||
const setTheme = useCallback((t: ThemeChoice) => {
|
||||
setThemeState(t);
|
||||
applyThemeToDom(t);
|
||||
writeUiPref(LS_KEY, t);
|
||||
}, []);
|
||||
|
||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||
useEffect(() => {
|
||||
if (theme !== 'auto') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => applyThemeToDom('auto');
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [theme]);
|
||||
|
||||
// Keep the DOM attribute in sync with state.
|
||||
useEffect(() => { applyThemeToDom(theme); }, [theme]);
|
||||
|
||||
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Portable UI preferences.
|
||||
//
|
||||
// A handful of small UI prefs historically lived only in the WebView's
|
||||
// localStorage, so they did NOT travel when the operator copied the OpsLog
|
||||
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||
// identical after a copy.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||
// localStorage copy is just a fast, synchronous cache).
|
||||
const PORTABLE_KEYS = [
|
||||
'opslog.theme', // UI colour theme (light-warm / dark-warm / graphite / high-contrast / auto)
|
||||
'hamlog.qsoLimit', // QSO list page size
|
||||
'bandmap.side', // band map docked left / right
|
||||
'bandmap.show', // band map open / closed
|
||||
'opslog.autofocusWB', // auto-focus Worked-before
|
||||
'hamlog.filterPresets', // Filter Builder saved presets
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
// Cluster filter selections — restored on reopen.
|
||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
// • DB has a value → copy it into localStorage (a copied folder restores it);
|
||||
// • DB empty, local set → seed the DB from local (migrates the current value).
|
||||
// Call it BEFORE the first render so the app's synchronous localStorage reads
|
||||
// already see the portable values — no per-component hydration needed.
|
||||
export async function syncPortablePrefs(): Promise<void> {
|
||||
await Promise.all(PORTABLE_KEYS.map(async (key) => {
|
||||
try {
|
||||
const db = await GetUIPref(key);
|
||||
const local = localStorage.getItem(key);
|
||||
if (db != null && db !== '') {
|
||||
if (db !== local) { try { localStorage.setItem(key, db); } catch { /* quota */ } }
|
||||
} else if (local != null && local !== '') {
|
||||
await SetUIPref(key, local);
|
||||
}
|
||||
} catch { /* backend not ready / no DB — keep whatever is in localStorage */ }
|
||||
}));
|
||||
}
|
||||
|
||||
// writeUiPref write-throughs a value to the local cache AND the portable DB.
|
||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||
export function writeUiPref(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
||||
}
|
||||
+20
-5
@@ -2,13 +2,28 @@ import React from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './style.css'
|
||||
import App from './App'
|
||||
import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
const root = createRoot(container!)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
)
|
||||
// Pull portable UI prefs (DB → localStorage) before the first render so the
|
||||
// app's synchronous reads see the values copied along with the data/ folder.
|
||||
// Render regardless of the outcome so a backend hiccup never blocks startup.
|
||||
syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
+613
-37
@@ -3,43 +3,616 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* ===== Warm taupe & burnt-orange palette =====
|
||||
A step darker than pure cream: the body is a soft taupe so white-ish
|
||||
cards lift off the surface, giving real depth without going dark.
|
||||
Borders are more defined; muted surfaces sit between bg and card so
|
||||
secondary chrome (toolbars, table headers) reads as quietly recessed.
|
||||
*/
|
||||
@theme {
|
||||
--color-background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||
--color-foreground: #1c1917; /* stone-900 */
|
||||
/* ============================================================================
|
||||
THEME SYSTEM
|
||||
----------------------------------------------------------------------------
|
||||
Every color is a semantic CSS variable declared per theme below. The
|
||||
`@theme inline` block points Tailwind's color utilities (bg-*, text-*,
|
||||
border-*, ring-*, …) directly at those variables, so switching the
|
||||
`data-theme` attribute on <html> reskins the whole UI at runtime with no
|
||||
rebuild. Four themes ship: two light-neutral surfaces and status families
|
||||
for OK / warning / caution / danger / info that adapt to each surface.
|
||||
|
||||
--color-card: #faf6ea; /* lifted off-white cream */
|
||||
--color-card-foreground: #1c1917;
|
||||
Semantic status vocabulary (use these, NOT raw palette colors):
|
||||
success — worked / confirmed / data / OK (was emerald)
|
||||
warning — new-band / attention (was amber)
|
||||
caution — new-mode / new-slot / lighter alert (was yellow)
|
||||
danger — new / split / negative (was rose)
|
||||
info — informational (was sky/blue)
|
||||
destructive — hard errors / live REC / delete (was red)
|
||||
Each family has: base (dots/bars/solid), -foreground (text on base),
|
||||
-muted (soft pill fill), -muted-foreground (text on pill), -border.
|
||||
========================================================================= */
|
||||
|
||||
--color-popover: #faf6ea;
|
||||
--color-popover-foreground: #1c1917;
|
||||
/* ---- Theme 1: Warm light (default) — warm taupe & burnt orange ---------- */
|
||||
:root,
|
||||
[data-theme="light-warm"] {
|
||||
--background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||
--foreground: #1c1917; /* stone-900 */
|
||||
--card: #faf6ea; /* lifted off-white cream */
|
||||
--card-foreground: #1c1917;
|
||||
--popover: #faf6ea;
|
||||
--popover-foreground: #1c1917;
|
||||
--primary: #b8410c; /* burnt orange, slightly deeper */
|
||||
--primary-foreground: #fff7ed;
|
||||
--secondary: #ddd2b8;
|
||||
--secondary-foreground: #1c1917;
|
||||
--muted: #ddd2b8; /* toolbars / table headers */
|
||||
--muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||
--accent: #f8d6a9; /* warm amber-cream */
|
||||
--accent-foreground: #7c2d12; /* orange-900 */
|
||||
--destructive: #a51c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #c8b994; /* deeper warm beige border */
|
||||
--input: #c8b994;
|
||||
--ring: #d97706; /* amber-600 focus ring */
|
||||
|
||||
--color-primary: #b8410c; /* burnt orange, slightly deeper */
|
||||
--color-primary-foreground: #fff7ed;
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--color-secondary: #ddd2b8; /* a touch under the bg */
|
||||
--color-secondary-foreground: #1c1917;
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--color-muted: #ddd2b8; /* toolbars / table headers */
|
||||
--color-muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--color-accent: #f8d6a9; /* warm amber-cream */
|
||||
--color-accent-foreground: #7c2d12; /* orange-900 */
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--color-destructive: #a51c1c;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
--color-border: #c8b994; /* deeper warm beige border */
|
||||
--color-input: #c8b994;
|
||||
--color-ring: #d97706; /* amber-600 focus ring */
|
||||
/* Band/mode matrix (BandSlotGrid) — original emerald/indigo/stone ramp. */
|
||||
--mx-call-conf: #15803d; /* call confirmed */
|
||||
--mx-call-work: #6ee7b7; /* call worked */
|
||||
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
||||
--mx-dx-work: #a5b4fc; /* entity worked */
|
||||
--mx-none: #e7e5e4; /* never worked */
|
||||
|
||||
--color-ok: #15803d;
|
||||
--color-warn: #b45309;
|
||||
--scrollbar-thumb: #b8a880;
|
||||
--scrollbar-thumb-hover: #968455;
|
||||
--card-shadow: 0 1px 2px rgba(28, 25, 23, 0.05), 0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
|
||||
[data-theme="light-cool"] {
|
||||
--background: #f4f6f8; /* cool blue-white */
|
||||
--foreground: #0f172a; /* slate-900 */
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f172a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0f172a;
|
||||
--primary: #2563eb; /* blue-600 */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #e2e8f0; /* slate-200 */
|
||||
--secondary-foreground: #0f172a;
|
||||
--muted: #e6ebf1; /* toolbars / table headers */
|
||||
--muted-foreground: #475569; /* slate-600 — readable on muted */
|
||||
--accent: #dbeafe; /* blue-100 */
|
||||
--accent-foreground: #1e3a8a; /* blue-900 */
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #d3dae2; /* cool slate border */
|
||||
--input: #d3dae2;
|
||||
--ring: #3b82f6; /* blue-500 focus ring */
|
||||
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #6ee7b7;
|
||||
--mx-dx-conf: #3730a3;
|
||||
--mx-dx-work: #a5b4fc;
|
||||
--mx-none: #e2e8f0;
|
||||
|
||||
--scrollbar-thumb: #b6c2d1;
|
||||
--scrollbar-thumb-hover: #8fa1b5;
|
||||
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
|
||||
[data-theme="light-sage"] {
|
||||
--background: #eef1ec; /* pale sage */
|
||||
--foreground: #1a2420; /* deep pine-ink */
|
||||
--card: #f8faf6; /* lifted off-white with a green cast */
|
||||
--card-foreground: #1a2420;
|
||||
--popover: #f8faf6;
|
||||
--popover-foreground: #1a2420;
|
||||
--primary: #2f855a; /* deep sage green */
|
||||
--primary-foreground: #f0fdf4;
|
||||
--secondary: #dde5db;
|
||||
--secondary-foreground: #1a2420;
|
||||
--muted: #dde5db; /* toolbars / table headers */
|
||||
--muted-foreground: #445048; /* muted pine — readable on muted */
|
||||
--accent: #d7e8d5; /* soft green */
|
||||
--accent-foreground: #1e4634; /* forest-900 */
|
||||
--destructive: #b91c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #cdd8ce; /* soft sage border */
|
||||
--input: #cdd8ce;
|
||||
--ring: #38a169; /* green focus ring */
|
||||
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #6ee7b7;
|
||||
--mx-dx-conf: #3730a3;
|
||||
--mx-dx-work: #a5b4fc;
|
||||
--mx-none: #dde5db;
|
||||
|
||||
--scrollbar-thumb: #b3c2b3;
|
||||
--scrollbar-thumb-hover: #91a591;
|
||||
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
|
||||
[data-theme="dim-slate"] {
|
||||
--background: #343b47; /* medium slate — light text, not deep dark */
|
||||
--foreground: #eceef2;
|
||||
--card: #3d4552; /* lifted panel */
|
||||
--card-foreground: #eceef2;
|
||||
--popover: #3d4552;
|
||||
--popover-foreground: #eceef2;
|
||||
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
|
||||
--primary-foreground: #241206;
|
||||
--secondary: #454e5c;
|
||||
--secondary-foreground: #eceef2;
|
||||
--muted: #414a58; /* toolbars / table headers */
|
||||
--muted-foreground: #b4bcc9; /* readable on muted */
|
||||
--accent: #495468; /* hover / selection tint */
|
||||
--accent-foreground: #dce3ef;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #1a0808;
|
||||
--destructive-muted: #3f1f1f;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #4e5867;
|
||||
--input: #4e5867;
|
||||
--ring: #fdba74; /* orange-300 focus ring */
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #06231a;
|
||||
--success-muted: #1c3d34;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #2a5f4c;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #241a06;
|
||||
--warning-muted: #41371a;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #5e4d22;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #242006;
|
||||
--caution-muted: #403b1a;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #5b5222;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #260a11;
|
||||
--danger-muted: #421f28;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #63303c;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #061f2b;
|
||||
--info-muted: #16384a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #1d5670;
|
||||
|
||||
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #4a5262;
|
||||
|
||||
--scrollbar-thumb: #565f70;
|
||||
--scrollbar-thumb-hover: #6b7588;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||
[data-theme="dark-warm"] {
|
||||
--background: #221d18;
|
||||
--foreground: #ede6d8;
|
||||
--card: #2e2820;
|
||||
--card-foreground: #ede6d8;
|
||||
--popover: #2e2820;
|
||||
--popover-foreground: #ede6d8;
|
||||
--primary: #e07a2e; /* brighter burnt orange for dark */
|
||||
--primary-foreground: #1a1512;
|
||||
--secondary: #3a3229;
|
||||
--secondary-foreground: #ede6d8;
|
||||
--muted: #352e26;
|
||||
--muted-foreground: #bcae9a;
|
||||
--accent: #4a3a28;
|
||||
--accent-foreground: #f8d6a9;
|
||||
--destructive: #e5484d;
|
||||
--destructive-foreground: #1a1512;
|
||||
--destructive-muted: #3a1414;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #473e33;
|
||||
--input: #473e33;
|
||||
--ring: #e8853a;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #0e2a20;
|
||||
--success-muted: #123024;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #1e5540;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #2a1f08;
|
||||
--warning-muted: #3a2e10;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #5c4a18;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #2a2607;
|
||||
--caution-muted: #38330f;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #574f18;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #2a0d13;
|
||||
--danger-muted: #3a1720;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #5c2530;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #08222e;
|
||||
--info-muted: #0c2a3a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #164a5c;
|
||||
|
||||
/* Matrix: bright confirmed, medium-solid worked (clearly visible on dark),
|
||||
warm-gray not-worked lifted off the card. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #453d33;
|
||||
|
||||
--scrollbar-thumb: #5c5044;
|
||||
--scrollbar-thumb-hover: #7a6a58;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 240, 220, 0.04);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 3: Graphite dark — cool slate, technical look --------------- */
|
||||
[data-theme="dark-graphite"] {
|
||||
--background: #16181d;
|
||||
--foreground: #e6e8ec;
|
||||
--card: #1f232b;
|
||||
--card-foreground: #e6e8ec;
|
||||
--popover: #1f232b;
|
||||
--popover-foreground: #e6e8ec;
|
||||
--primary: #f97316; /* orange-500 pops on slate */
|
||||
--primary-foreground: #0a0c10;
|
||||
--secondary: #272c35;
|
||||
--secondary-foreground: #e6e8ec;
|
||||
--muted: #232830;
|
||||
--muted-foreground: #9aa2b1;
|
||||
--accent: #2a3240;
|
||||
--accent-foreground: #cbd5e5;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #0a0c10;
|
||||
--destructive-muted: #331414;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #2e343f;
|
||||
--input: #2e343f;
|
||||
--ring: #fb8c3c;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #06231a;
|
||||
--success-muted: #0f2e26;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #17513f;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #241a06;
|
||||
--warning-muted: #33290f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3f16;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #242006;
|
||||
--caution-muted: #322e0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4c4416;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #260a11;
|
||||
--danger-muted: #34141d;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #532430;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #061f2b;
|
||||
--info-muted: #0b2938;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #124659;
|
||||
|
||||
/* Matrix: bright confirmed, medium-solid worked, cool-gray not-worked. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #333a45;
|
||||
|
||||
--scrollbar-thumb: #3a4150;
|
||||
--scrollbar-thumb-hover: #4e5768;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(230, 232, 236, 0.04);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 4: High contrast — pure black / white, max legibility ------- */
|
||||
[data-theme="high-contrast"] {
|
||||
--background: #000000;
|
||||
--foreground: #ffffff;
|
||||
--card: #0d0d0d;
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #0d0d0d;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ff7a1a;
|
||||
--primary-foreground: #000000;
|
||||
--secondary: #1a1a1a;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #141414;
|
||||
--muted-foreground: #d4d4d4;
|
||||
--accent: #262626;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff4d4d;
|
||||
--destructive-foreground: #000000;
|
||||
--destructive-muted: #2a0808;
|
||||
--destructive-muted-foreground: #ff9999;
|
||||
--border: #3f3f3f;
|
||||
--input: #3f3f3f;
|
||||
--ring: #ffab5e;
|
||||
|
||||
--success: #22e37a;
|
||||
--success-foreground: #002313;
|
||||
--success-muted: #06231a;
|
||||
--success-muted-foreground: #6affb0;
|
||||
--success-border: #0f6b4a;
|
||||
|
||||
--warning: #ffcc33;
|
||||
--warning-foreground: #201900;
|
||||
--warning-muted: #2a2205;
|
||||
--warning-muted-foreground: #ffe08a;
|
||||
--warning-border: #6b5510;
|
||||
|
||||
--caution: #ffe14d;
|
||||
--caution-foreground: #201d00;
|
||||
--caution-muted: #2a2705;
|
||||
--caution-muted-foreground: #fff08a;
|
||||
--caution-border: #6b6210;
|
||||
|
||||
--danger: #ff6b7d;
|
||||
--danger-foreground: #23000a;
|
||||
--danger-muted: #2a0a12;
|
||||
--danger-muted-foreground: #ffb3bd;
|
||||
--danger-border: #6b1f2c;
|
||||
|
||||
--info: #4dc4ff;
|
||||
--info-foreground: #001a26;
|
||||
--info-muted: #052330;
|
||||
--info-muted-foreground: #a3e0ff;
|
||||
--info-border: #0f5a6b;
|
||||
|
||||
/* Matrix: max-contrast ramp on pure black. */
|
||||
--mx-call-conf: #2ee87f;
|
||||
--mx-call-work: #17a85a;
|
||||
--mx-dx-conf: #7d97ff;
|
||||
--mx-dx-work: #3646b0;
|
||||
--mx-none: #3a3a3a;
|
||||
|
||||
--scrollbar-thumb: #4a4a4a;
|
||||
--scrollbar-thumb-hover: #666666;
|
||||
--card-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||
automatic inversion.
|
||||
|
||||
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||
shows.
|
||||
|
||||
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||
recedes toward the surface instead of glowing. */
|
||||
:root,
|
||||
[data-theme="light-warm"],
|
||||
[data-theme="light-cool"],
|
||||
[data-theme="light-sage"] {
|
||||
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||
--chart-2: #1baf7a; /* aqua */
|
||||
--chart-3: #eda100; /* yellow */
|
||||
--chart-4: #008300; /* green */
|
||||
--chart-5: #4a3aa7; /* violet */
|
||||
--chart-6: #e34948; /* red */
|
||||
--chart-7: #e87ba4; /* magenta */
|
||||
--chart-8: #eb6834; /* orange */
|
||||
|
||||
--chart-seq-1: #cde2fb;
|
||||
--chart-seq-2: #9ec5f4;
|
||||
--chart-seq-3: #6da7ec;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #256abf;
|
||||
--chart-seq-6: #104281;
|
||||
}
|
||||
|
||||
[data-theme="dim-slate"],
|
||||
[data-theme="dark-warm"],
|
||||
[data-theme="dark-graphite"],
|
||||
[data-theme="high-contrast"] {
|
||||
--chart-1: #3987e5;
|
||||
--chart-2: #199e70;
|
||||
--chart-3: #c98500;
|
||||
--chart-4: #008300;
|
||||
--chart-5: #9085e9;
|
||||
--chart-6: #e66767;
|
||||
--chart-7: #d55181;
|
||||
--chart-8: #d95926;
|
||||
|
||||
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||
--chart-seq-1: #0d366b;
|
||||
--chart-seq-2: #184f95;
|
||||
--chart-seq-3: #256abf;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #6da7ec;
|
||||
--chart-seq-6: #9ec5f4;
|
||||
}
|
||||
|
||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||
the generated utilities reference var(--…) directly, so overriding a var in
|
||||
a [data-theme] block re-skins every utility at runtime. */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive-muted: var(--destructive-muted);
|
||||
--color-destructive-muted-foreground: var(--destructive-muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-success-muted: var(--success-muted);
|
||||
--color-success-muted-foreground: var(--success-muted-foreground);
|
||||
--color-success-border: var(--success-border);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-warning-muted: var(--warning-muted);
|
||||
--color-warning-muted-foreground: var(--warning-muted-foreground);
|
||||
--color-warning-border: var(--warning-border);
|
||||
|
||||
--color-caution: var(--caution);
|
||||
--color-caution-foreground: var(--caution-foreground);
|
||||
--color-caution-muted: var(--caution-muted);
|
||||
--color-caution-muted-foreground: var(--caution-muted-foreground);
|
||||
--color-caution-border: var(--caution-border);
|
||||
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-foreground: var(--danger-foreground);
|
||||
--color-danger-muted: var(--danger-muted);
|
||||
--color-danger-muted-foreground: var(--danger-muted-foreground);
|
||||
--color-danger-border: var(--danger-border);
|
||||
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
--color-info-muted: var(--info-muted);
|
||||
--color-info-muted-foreground: var(--info-muted-foreground);
|
||||
--color-info-border: var(--info-border);
|
||||
|
||||
--color-mx-call-conf: var(--mx-call-conf);
|
||||
--color-mx-call-work: var(--mx-call-work);
|
||||
--color-mx-dx-conf: var(--mx-dx-conf);
|
||||
--color-mx-dx-work: var(--mx-dx-work);
|
||||
--color-mx-none: var(--mx-none);
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
@@ -50,29 +623,32 @@
|
||||
@layer base {
|
||||
* { @apply border-border; }
|
||||
html, body, #root { height: 100%; }
|
||||
/* Root rem base. Tailwind sizing (text/heights/padding/gaps) is in rem, so
|
||||
this is the single global density knob — shrinks the whole UI uniformly
|
||||
while Leaflet maps (px-based) stay correct. Default browser base is 16px. */
|
||||
html { font-size: 13px; }
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subtle elevation on every Card-styled surface so cards visibly sit on
|
||||
top of the taupe background — paper-on-paper feel. */
|
||||
top of the background — paper-on-paper feel, tuned per theme. */
|
||||
.bg-card {
|
||||
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05),
|
||||
0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* Warm scrollbar tuned to the deeper bg */
|
||||
/* Scrollbar tuned to each theme's surface */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #b8a880;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--color-background);
|
||||
border: 2px solid var(--background);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #968455; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.19.8';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
Vendored
+662
-5
@@ -1,34 +1,125 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {adif} from '../models';
|
||||
import {qso} from '../models';
|
||||
import {main} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {adif} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {antgenius} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {solar} from '../models';
|
||||
import {winkeyer} from '../models';
|
||||
import {alerts} from '../models';
|
||||
import {audio} from '../models';
|
||||
import {contest} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {udp} from '../models';
|
||||
import {lotwusers} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
|
||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||
|
||||
export function ADIFVersion():Promise<string>;
|
||||
|
||||
export function ActivateProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||
|
||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||
|
||||
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
|
||||
|
||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
|
||||
export function AudioMonitorActive():Promise<boolean>;
|
||||
|
||||
export function AudioStartMonitor():Promise<void>;
|
||||
|
||||
export function AudioStartTX():Promise<void>;
|
||||
|
||||
export function AudioStopMonitor():Promise<void>;
|
||||
|
||||
export function AudioStopTX():Promise<void>;
|
||||
|
||||
export function AudioTXActive():Promise<boolean>;
|
||||
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardFields():Promise<Array<string>>;
|
||||
|
||||
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||
|
||||
export function AwardsFolder():Promise<string>;
|
||||
|
||||
export function BrowseExecutable():Promise<string>;
|
||||
|
||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
|
||||
export function ClearLookupCache():Promise<void>;
|
||||
|
||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||
|
||||
export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>;
|
||||
|
||||
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||
|
||||
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>;
|
||||
|
||||
export function CreateDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function DVKCancelRecord():Promise<void>;
|
||||
|
||||
export function DVKPlay(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKPreview(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKStartRecord(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKStop():Promise<void>;
|
||||
|
||||
export function DVKStopRecord():Promise<void>;
|
||||
|
||||
export function DXCCForCountry(arg1:string):Promise<number>;
|
||||
|
||||
export function DXCCName(arg1:number):Promise<string>;
|
||||
|
||||
export function DeleteAlertRule(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteAllQSO():Promise<number>;
|
||||
|
||||
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function DeleteClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteOperatingAntenna(arg1:number):Promise<void>;
|
||||
@@ -39,80 +130,542 @@ export function DeleteProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteQSO(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||
|
||||
export function DisconnectAllClusters():Promise<void>;
|
||||
|
||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||
|
||||
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
|
||||
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||
|
||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportAward(arg1:string):Promise<string>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||
|
||||
export function ExportCabrilloFiltered(arg1:string,arg2:qso.QueryFilter):Promise<main.CabrilloResult>;
|
||||
|
||||
export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<main.CabrilloResult>;
|
||||
|
||||
export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
|
||||
|
||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function FlexATUBypass():Promise<void>;
|
||||
|
||||
export function FlexATUStart():Promise<void>;
|
||||
|
||||
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetActiveSlice(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWFilter(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMute(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetPower(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetRIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetWNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
|
||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||
|
||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||
|
||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||
|
||||
export function GetAward(arg1:string):Promise<award.Result>;
|
||||
|
||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
|
||||
|
||||
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
||||
|
||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||
|
||||
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||
|
||||
export function GetAwards():Promise<Array<award.Result>>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
|
||||
export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetCWDecoderPitch():Promise<number>;
|
||||
|
||||
export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||
|
||||
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
|
||||
|
||||
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
||||
|
||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||
|
||||
export function GetDataDir():Promise<string>;
|
||||
|
||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||
|
||||
export function GetEmailSettings():Promise<main.EmailSettings>;
|
||||
|
||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
|
||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
|
||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||
|
||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||
|
||||
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||
|
||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||
|
||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||
|
||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||
|
||||
export function GetPOTAToken():Promise<string>;
|
||||
|
||||
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
|
||||
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||
|
||||
export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetUIPref(arg1:string):Promise<string>;
|
||||
|
||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||
|
||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||
|
||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IcomRefresh():Promise<void>;
|
||||
|
||||
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
||||
|
||||
export function IcomSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function IcomSetAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function IcomSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetAPF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetAntenna(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAntiVOX(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetBreakIn(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetComp(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetCompLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetFilter(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetManualNotch(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetMicGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetMonLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetMonitor(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetNotchPos(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPBTInner(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPBTOuter(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPTT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetPreamp(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFPower(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRIT(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScope(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScopeEdges(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetSquelch(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetVOXGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetXITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomStopCW():Promise<void>;
|
||||
|
||||
export function IcomTune():Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||
|
||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||
|
||||
export function ListAlertRules():Promise<Array<alerts.Rule>>;
|
||||
|
||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||
|
||||
export function ListContests():Promise<Array<contest.Def>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
|
||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function ListSerialPorts():Promise<Array<string>>;
|
||||
|
||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||
|
||||
export function NetActiveList():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function NetClose():Promise<void>;
|
||||
|
||||
export function NetCreate(arg1:string):Promise<netctl.Net>;
|
||||
|
||||
export function NetDeactivate(arg1:number):Promise<number>;
|
||||
|
||||
export function NetDelete(arg1:string):Promise<void>;
|
||||
|
||||
export function NetDiscardActive(arg1:number):Promise<void>;
|
||||
|
||||
export function NetList():Promise<Array<netctl.Net>>;
|
||||
|
||||
export function NetLookup(arg1:string):Promise<netctl.Station>;
|
||||
|
||||
export function NetOpen(arg1:string):Promise<void>;
|
||||
|
||||
export function NetOpenID():Promise<string>;
|
||||
|
||||
export function NetRename(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
|
||||
|
||||
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
|
||||
|
||||
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||
|
||||
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function OpenADIFFile():Promise<string>;
|
||||
|
||||
export function OpenAwardsFolder():Promise<void>;
|
||||
|
||||
export function OpenDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
|
||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||
|
||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
|
||||
export function PickOpenDatabase():Promise<string>;
|
||||
|
||||
export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||
|
||||
export function QSLDefaultTemplateID():Promise<number>;
|
||||
|
||||
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
||||
|
||||
export function QSLFlagDataURL(arg1:string):Promise<string>;
|
||||
|
||||
export function QSLFonts():Promise<Array<main.QSLFontInfo>>;
|
||||
|
||||
export function QSLGenerateProposals(arg1:Array<string>):Promise<Array<string>>;
|
||||
|
||||
export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
|
||||
|
||||
export function QSLGetTemplate(arg1:number):Promise<string>;
|
||||
|
||||
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
||||
|
||||
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
||||
|
||||
export function QSLPickPhotos():Promise<Array<string>>;
|
||||
|
||||
export function QSLPreviewDataURL(arg1:number):Promise<string>;
|
||||
|
||||
export function QSLResolvePreview(arg1:string):Promise<string>;
|
||||
|
||||
export function QSLSaveEmailTemplates(arg1:main.QSLEmailTemplates):Promise<void>;
|
||||
|
||||
export function QSLSavePreview(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function QSLSaveTemplate(arg1:number,arg2:string,arg3:string,arg4:boolean):Promise<number>;
|
||||
|
||||
export function QSLSetDefaultTemplate(arg1:number):Promise<void>;
|
||||
|
||||
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
|
||||
|
||||
export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
|
||||
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function RescanAwards():Promise<void>;
|
||||
|
||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
export function RestartApp():Promise<void>;
|
||||
|
||||
export function RestartQSORecorder():Promise<void>;
|
||||
|
||||
export function RetryOfflineSync():Promise<number>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
@@ -123,56 +676,160 @@ export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||
|
||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||
|
||||
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
||||
|
||||
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
||||
|
||||
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||
|
||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||
|
||||
export function SaveCabrilloFile():Promise<string>;
|
||||
|
||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||
|
||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||
|
||||
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||
|
||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
|
||||
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
|
||||
|
||||
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
||||
|
||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||
|
||||
export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
||||
|
||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||
|
||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
|
||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||
|
||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||
|
||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||
|
||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||
|
||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
export function TestEQSLUpload():Promise<string>;
|
||||
|
||||
export function TestEmail(arg1:string):Promise<void>;
|
||||
|
||||
export function TestHRDLogUpload():Promise<string>;
|
||||
|
||||
export function TestLoTWUpload():Promise<string>;
|
||||
|
||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||
|
||||
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
|
||||
|
||||
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function UltrabeamRetract():Promise<void>;
|
||||
|
||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||
|
||||
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
||||
|
||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UploadCallsign(arg1:string):Promise<string>;
|
||||
|
||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||
|
||||
export function WinkeyerBackspace():Promise<void>;
|
||||
|
||||
export function WinkeyerConnect():Promise<void>;
|
||||
|
||||
export function WinkeyerDisconnect():Promise<void>;
|
||||
|
||||
export function WinkeyerSend(arg1:string):Promise<void>;
|
||||
|
||||
export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function WinkeyerStop():Promise<void>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
+1296
-4
File diff suppressed because it is too large
Load Diff
+2686
-43
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,26 @@ module hamlog
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/braheezy/shine-mp3 v0.1.0
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/moutend/go-wca v0.3.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
go.bug.st/serial v1.7.1
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.22.0
|
||||
golang.org/x/text v0.37.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
||||
github.com/braheezy/shine-mp3 v0.1.0/go.mod h1:0H/pmcpFAd+Fnrj6Pc7du7wL36U/HqtfcgPJuCgc1L4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
@@ -40,6 +47,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moutend/go-wca v0.3.0 h1:IzhsQ44zBzMdT42xlBjiLSVya9cPYOoKx9E+yXVhFo8=
|
||||
github.com/moutend/go-wca v0.3.0/go.mod h1:7VrPO512jnjFGJ6rr+zOoCfiYjOHRPNfbttJuxAurcw=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -69,15 +78,20 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
|
||||
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
|
||||
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -88,11 +102,11 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Loggers (notably Log4OM's UDP/exported ADIF) sometimes declare a field
|
||||
// length as the CHARACTER count instead of the UTF-8 byte count, truncating
|
||||
// multibyte values mid-rune. The parser must recover the full value.
|
||||
func TestCharCountLengthRepair(t *testing.T) {
|
||||
cases := []struct{ name, wantQTH, wantName, adi string }{
|
||||
{
|
||||
name: "latin",
|
||||
wantQTH: "Tóalmás", // 7 chars / 9 bytes, declared 7
|
||||
wantName: "Laci Budai",
|
||||
adi: "<EOH>\n<CALL:5>HA5XY<QTH:7>Tóalmás<NAME:10>Laci Budai<EOR>\n",
|
||||
},
|
||||
{
|
||||
name: "cyrillic",
|
||||
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got Record
|
||||
if err := Parse(strings.NewReader(c.adi), func(r Record) error { got = r; return nil }); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got["qth"] != c.wantQTH {
|
||||
t.Errorf("qth = %q, want %q", got["qth"], c.wantQTH)
|
||||
}
|
||||
if got["name"] != c.wantName {
|
||||
t.Errorf("name = %q, want %q", got["name"], c.wantName)
|
||||
}
|
||||
if !utf8.ValidString(got["name"]) || !utf8.ValidString(got["qth"]) {
|
||||
t.Errorf("result not valid UTF-8: name=%q qth=%q", got["name"], got["qth"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+118
-14
@@ -27,31 +27,61 @@ type Exporter struct {
|
||||
// AppName / AppVersion populate the ADIF header comments. Optional.
|
||||
AppName string
|
||||
AppVersion string
|
||||
|
||||
// IncludeAppFields controls whether application-specific fields (ADIF
|
||||
// "APP_<programid>_<name>" tags, e.g. Log4OM's APP_LOG4OM_* or our own
|
||||
// OpsLog extensions) are written. Leave false for a clean standard-ADIF
|
||||
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||
// round-trip that preserves everything.
|
||||
IncludeAppFields bool
|
||||
}
|
||||
|
||||
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
||||
// by-ids) all match this shape so the document writer stays source-agnostic.
|
||||
type iterator func(ctx context.Context, fn func(qso.QSO) error) error
|
||||
|
||||
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
||||
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
|
||||
return e.exportFileWith(ctx, path, e.Repo.IterateAll)
|
||||
}
|
||||
|
||||
// ExportFileFiltered writes only the QSOs matching f (no row limit).
|
||||
func (e *Exporter) ExportFileFiltered(ctx context.Context, path string, f qso.QueryFilter) (ExportResult, error) {
|
||||
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
||||
return e.Repo.IterateFiltered(ctx, f, fn)
|
||||
})
|
||||
}
|
||||
|
||||
// ExportFileByIDs writes only the QSOs with the given ids.
|
||||
func (e *Exporter) ExportFileByIDs(ctx context.Context, path string, ids []int64) (ExportResult, error) {
|
||||
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
||||
return e.Repo.IterateByIDs(ctx, ids, fn)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Exporter) exportFileWith(ctx context.Context, path string, iter iterator) (ExportResult, error) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
count, err := e.Export(ctx, f)
|
||||
count, err := e.writeDoc(ctx, f, iter)
|
||||
if err != nil {
|
||||
return ExportResult{Path: path, Count: count}, err
|
||||
}
|
||||
info, _ := f.Stat()
|
||||
return ExportResult{
|
||||
Path: path,
|
||||
Count: count,
|
||||
SizeKB: info.Size() / 1024,
|
||||
}, nil
|
||||
return ExportResult{Path: path, Count: count, SizeKB: info.Size() / 1024}, nil
|
||||
}
|
||||
|
||||
// Export writes a complete ADIF document (header + records + EOF) to w.
|
||||
// Returns the number of QSOs successfully written.
|
||||
// Export writes a complete ADIF document (header + records + EOF) to w for
|
||||
// every QSO. Returns the number of QSOs written.
|
||||
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
return e.writeDoc(ctx, w, e.Repo.IterateAll)
|
||||
}
|
||||
|
||||
// writeDoc writes the ADIF header then streams every QSO from iter.
|
||||
func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (int, error) {
|
||||
bw := bufio.NewWriterSize(w, 64*1024)
|
||||
defer bw.Flush()
|
||||
|
||||
@@ -62,15 +92,15 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
ver := strings.TrimSpace(e.AppVersion)
|
||||
now := time.Now().UTC().Format("20060102 150405")
|
||||
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
|
||||
fmt.Fprintf(bw, "<ADIF_VER:5>3.1.0 <PROGRAMID:%d>%s", len(app), app)
|
||||
fmt.Fprintf(bw, "<ADIF_VER:%d>%s <PROGRAMID:%d>%s", len(adifVersion), adifVersion, len(app), app)
|
||||
if ver != "" {
|
||||
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
|
||||
}
|
||||
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
|
||||
|
||||
count := 0
|
||||
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
||||
writeRecord(bw, q)
|
||||
err := iter(ctx, func(q qso.QSO) error {
|
||||
writeRecord(bw, q, e.IncludeAppFields)
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
@@ -84,16 +114,48 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
func SingleRecordADIF(q qso.QSO) string {
|
||||
var b strings.Builder
|
||||
bw := bufio.NewWriter(&b)
|
||||
writeRecord(bw, q)
|
||||
// Uploads target other services — keep it standard (no app-specific tags).
|
||||
writeRecord(bw, q, false)
|
||||
bw.Flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||
// so it can be written out and read back with nothing dropped. Used by the
|
||||
// offline queue: a QSO parked in the safety file must come back identical
|
||||
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
|
||||
func FullRecordADIF(q qso.QSO) string {
|
||||
var b strings.Builder
|
||||
bw := bufio.NewWriter(&b)
|
||||
writeRecord(bw, q, true)
|
||||
bw.Flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||
func BatchRecordsADIF(records []string) string {
|
||||
var b strings.Builder
|
||||
now := time.Now().UTC().Format("20060102 150405")
|
||||
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
|
||||
len(adifVersion), adifVersion, now)
|
||||
for _, r := range records {
|
||||
r = strings.TrimRight(r, "\r\n")
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(r)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||
func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
||||
// --- Core ---
|
||||
writeField(bw, "CALL", q.Callsign)
|
||||
|
||||
@@ -169,6 +231,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
||||
@@ -215,9 +279,49 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
writeField(bw, "COMMENT", q.Comment)
|
||||
writeField(bw, "NOTES", q.Notes)
|
||||
|
||||
// --- ADIF 3.1.7 additional promoted fields ---
|
||||
writeField(bw, "SIG", q.SIG)
|
||||
writeField(bw, "SIG_INFO", q.SIGInfo)
|
||||
writeField(bw, "MY_SIG", q.MySIG)
|
||||
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
|
||||
writeField(bw, "WWFF_REF", q.WWFFRef)
|
||||
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
|
||||
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
|
||||
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
|
||||
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
|
||||
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
|
||||
writeFloatPtr(bw, "SFI", q.SFI, 0)
|
||||
writeField(bw, "SKCC", q.SKCC)
|
||||
writeField(bw, "FISTS", q.FISTS)
|
||||
writeField(bw, "TEN_TEN", q.TenTen)
|
||||
writeField(bw, "CONTACTED_OP", q.ContactedOp)
|
||||
writeField(bw, "EQ_CALL", q.EqCall)
|
||||
writeField(bw, "PFX", q.PFX)
|
||||
writeField(bw, "MY_NAME", q.MyName)
|
||||
writeField(bw, "CLASS", q.Class)
|
||||
writeField(bw, "DARC_DOK", q.DarcDOK)
|
||||
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
|
||||
writeField(bw, "REGION", q.Region)
|
||||
writeField(bw, "SILENT_KEY", q.SilentKey)
|
||||
writeField(bw, "SWL", q.SWL)
|
||||
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
|
||||
writeField(bw, "QSO_RANDOM", q.QSORandom)
|
||||
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
|
||||
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
|
||||
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
|
||||
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
|
||||
|
||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
||||
// application-specific tags AND any non-standard / vendor tag — keeping
|
||||
// the file strictly portable to other loggers. Full mode keeps every
|
||||
// extra for a lossless OpsLog round-trip.
|
||||
for k, v := range q.Extras {
|
||||
writeField(bw, strings.ToUpper(k), v)
|
||||
tag := strings.ToUpper(k)
|
||||
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||
continue
|
||||
}
|
||||
writeField(bw, tag, v)
|
||||
}
|
||||
|
||||
bw.WriteString("<EOR>\n")
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package adif
|
||||
|
||||
import "strings"
|
||||
|
||||
// This file embeds the complete ADIF 3.1.7 QSO-field dictionary. It is the
|
||||
// single source of truth for:
|
||||
// - the generic "ADIF fields" editor in the UI (any field becomes editable),
|
||||
// - the "standard ADIF" export mode (only spec fields are emitted),
|
||||
// - import diagnostics (a tag absent here is non-standard / vendor-specific).
|
||||
//
|
||||
// Promoted fields (those with dedicated QSO columns) are flagged Promoted=true
|
||||
// so the UI can hide them from the generic editor — they already have proper
|
||||
// inputs in the main tabs.
|
||||
|
||||
// FieldKind is a coarse input type used by the generic editor to pick a widget.
|
||||
type FieldKind string
|
||||
|
||||
const (
|
||||
KindText FieldKind = "text" // String / IntlString / MultilineString
|
||||
KindNumber FieldKind = "number" // Number / PositiveInteger
|
||||
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
|
||||
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
|
||||
KindBool FieldKind = "boolean" // Boolean (Y/N)
|
||||
KindEnum FieldKind = "enum" // Enumeration
|
||||
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
|
||||
)
|
||||
|
||||
// FieldDef describes one ADIF QSO field.
|
||||
type FieldDef struct {
|
||||
Name string `json:"name"` // canonical uppercase ADIF tag
|
||||
Kind FieldKind `json:"kind"` // editor widget hint
|
||||
Category string `json:"category"` // grouping for the UI
|
||||
Promoted bool `json:"promoted"` // has a dedicated QSO column
|
||||
Deprecated bool `json:"deprecated"` // import-only per the spec
|
||||
Intl bool `json:"intl"` // *_INTL UTF-8 variant
|
||||
}
|
||||
|
||||
// adifVersion is the ADIF spec version OpsLog targets for import/export.
|
||||
const adifVersion = "3.1.7"
|
||||
|
||||
// ADIFVersion returns the ADIF spec version OpsLog conforms to.
|
||||
func ADIFVersion() string { return adifVersion }
|
||||
|
||||
// Fields is the full ADIF 3.1.7 QSO-field set. Order is alphabetical within
|
||||
// each category; categories order is roughly "most-used first" for the UI.
|
||||
var Fields = []FieldDef{
|
||||
// ── Core / contact ──────────────────────────────────────────────
|
||||
{Name: "CALL", Kind: KindText, Category: "Core", Promoted: true},
|
||||
{Name: "QSO_DATE", Kind: KindDate, Category: "Core", Promoted: true},
|
||||
{Name: "TIME_ON", Kind: KindTime, Category: "Core", Promoted: true},
|
||||
{Name: "QSO_DATE_OFF", Kind: KindDate, Category: "Core", Promoted: true},
|
||||
{Name: "TIME_OFF", Kind: KindTime, Category: "Core", Promoted: true},
|
||||
{Name: "BAND", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||
{Name: "BAND_RX", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||
{Name: "MODE", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||
{Name: "SUBMODE", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||
{Name: "FREQ", Kind: KindNumber, Category: "Core", Promoted: true},
|
||||
{Name: "FREQ_RX", Kind: KindNumber, Category: "Core", Promoted: true},
|
||||
{Name: "RST_SENT", Kind: KindText, Category: "Core", Promoted: true},
|
||||
{Name: "RST_RCVD", Kind: KindText, Category: "Core", Promoted: true},
|
||||
{Name: "QSO_COMPLETE", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||
{Name: "QSO_RANDOM", Kind: KindBool, Category: "Core", Promoted: true},
|
||||
{Name: "SWL", Kind: KindBool, Category: "Core", Promoted: true},
|
||||
|
||||
// ── Contacted station ───────────────────────────────────────────
|
||||
{Name: "NAME", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "NAME_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||
{Name: "QTH", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "QTH_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||
{Name: "ADDRESS", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "ADDRESS_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||
{Name: "EMAIL", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "WEB", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "GRIDSQUARE", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "GRIDSQUARE_EXT", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "VUCC_GRIDS", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "COUNTRY", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "COUNTRY_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||
{Name: "STATE", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||
{Name: "CNTY", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||
{Name: "CNTY_ALT", Kind: KindEnum, Category: "Contacted"},
|
||||
{Name: "DXCC", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||
{Name: "CONT", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||
{Name: "CQZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||
{Name: "ITUZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||
{Name: "IOTA", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "IOTA_ISLAND_ID", Kind: KindText, Category: "Contacted"},
|
||||
{Name: "REGION", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||
{Name: "PFX", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "AGE", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||
{Name: "LAT", Kind: KindLoc, Category: "Contacted", Promoted: true},
|
||||
{Name: "LON", Kind: KindLoc, Category: "Contacted", Promoted: true},
|
||||
{Name: "ALTITUDE", Kind: KindNumber, Category: "Contacted"},
|
||||
{Name: "DISTANCE", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||
{Name: "RIG", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "RIG_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||
{Name: "ANT", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "CONTACTED_OP", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "EQ_CALL", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||
{Name: "GUEST_OP", Kind: KindText, Category: "Contacted", Deprecated: true},
|
||||
{Name: "OWNER_CALLSIGN", Kind: KindText, Category: "Contacted"},
|
||||
{Name: "SILENT_KEY", Kind: KindBool, Category: "Contacted", Promoted: true},
|
||||
{Name: "USACA_COUNTIES", Kind: KindText, Category: "Contacted"},
|
||||
|
||||
// ── Special activity (POTA/SOTA/WWFF/SIG) ───────────────────────
|
||||
{Name: "SIG", Kind: KindText, Category: "Activity", Promoted: true},
|
||||
{Name: "SIG_INFO", Kind: KindText, Category: "Activity", Promoted: true},
|
||||
{Name: "SIG_INTL", Kind: KindText, Category: "Activity", Intl: true},
|
||||
{Name: "SIG_INFO_INTL", Kind: KindText, Category: "Activity", Intl: true},
|
||||
{Name: "POTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
|
||||
{Name: "SOTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
|
||||
{Name: "WWFF_REF", Kind: KindText, Category: "Activity", Promoted: true},
|
||||
|
||||
// ── Power / propagation / space wx ──────────────────────────────
|
||||
{Name: "TX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||
{Name: "RX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||
{Name: "A_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||
{Name: "K_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||
{Name: "SFI", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||
{Name: "PROP_MODE", Kind: KindEnum, Category: "Propagation", Promoted: true},
|
||||
{Name: "SAT_NAME", Kind: KindText, Category: "Propagation", Promoted: true},
|
||||
{Name: "SAT_MODE", Kind: KindText, Category: "Propagation", Promoted: true},
|
||||
{Name: "ANT_AZ", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||
{Name: "ANT_EL", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||
{Name: "ANT_PATH", Kind: KindEnum, Category: "Propagation", Promoted: true},
|
||||
{Name: "FORCE_INIT", Kind: KindBool, Category: "Propagation"},
|
||||
{Name: "MAX_BURSTS", Kind: KindNumber, Category: "Propagation"},
|
||||
{Name: "MS_SHOWER", Kind: KindText, Category: "Propagation"},
|
||||
{Name: "NR_BURSTS", Kind: KindNumber, Category: "Propagation"},
|
||||
{Name: "NR_PINGS", Kind: KindNumber, Category: "Propagation"},
|
||||
|
||||
// ── QSL / confirmations ─────────────────────────────────────────
|
||||
{Name: "QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true},
|
||||
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
|
||||
{Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
|
||||
{Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
|
||||
{Name: "LOTW_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "LOTW_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "LOTW_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "LOTW_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "EQSL_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "EQSL_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "EQSL_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "EQSL_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "EQSL_AG", Kind: KindBool, Category: "QSL"},
|
||||
{Name: "DCL_QSL_SENT", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "DCL_QSL_RCVD", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "DCL_QSLSDATE", Kind: KindDate, Category: "QSL"},
|
||||
{Name: "DCL_QSLRDATE", Kind: KindDate, Category: "QSL"},
|
||||
{Name: "CLUBLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "CLUBLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "HRDLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "HRDLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
{Name: "HAMQTH_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||
|
||||
// ── Awards / credits ────────────────────────────────────────────
|
||||
{Name: "CREDIT_SUBMITTED", Kind: KindText, Category: "Awards", Promoted: true},
|
||||
{Name: "CREDIT_GRANTED", Kind: KindText, Category: "Awards", Promoted: true},
|
||||
{Name: "AWARD_SUBMITTED", Kind: KindText, Category: "Awards"},
|
||||
{Name: "AWARD_GRANTED", Kind: KindText, Category: "Awards"},
|
||||
|
||||
// ── Contest ─────────────────────────────────────────────────────
|
||||
{Name: "CONTEST_ID", Kind: KindText, Category: "Contest", Promoted: true},
|
||||
{Name: "SRX", Kind: KindNumber, Category: "Contest", Promoted: true},
|
||||
{Name: "STX", Kind: KindNumber, Category: "Contest", Promoted: true},
|
||||
{Name: "SRX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
|
||||
{Name: "STX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
|
||||
{Name: "CHECK", Kind: KindText, Category: "Contest", Promoted: true},
|
||||
{Name: "CLASS", Kind: KindText, Category: "Contest", Promoted: true},
|
||||
{Name: "PRECEDENCE", Kind: KindText, Category: "Contest", Promoted: true},
|
||||
{Name: "ARRL_SECT", Kind: KindEnum, Category: "Contest", Promoted: true},
|
||||
|
||||
// ── Club memberships ────────────────────────────────────────────
|
||||
{Name: "SKCC", Kind: KindText, Category: "Clubs", Promoted: true},
|
||||
{Name: "FISTS", Kind: KindNumber, Category: "Clubs", Promoted: true},
|
||||
{Name: "FISTS_CC", Kind: KindNumber, Category: "Clubs"},
|
||||
{Name: "TEN_TEN", Kind: KindNumber, Category: "Clubs", Promoted: true},
|
||||
{Name: "UKSMG", Kind: KindNumber, Category: "Clubs"},
|
||||
{Name: "DARC_DOK", Kind: KindText, Category: "Clubs", Promoted: true},
|
||||
|
||||
// ── Morse key (3.1.5+) ──────────────────────────────────────────
|
||||
{Name: "MORSE_KEY_TYPE", Kind: KindEnum, Category: "Morse key"},
|
||||
{Name: "MORSE_KEY_INFO", Kind: KindText, Category: "Morse key"},
|
||||
|
||||
// ── Misc / crypto ───────────────────────────────────────────────
|
||||
{Name: "COMMENT", Kind: KindText, Category: "Misc", Promoted: true},
|
||||
{Name: "COMMENT_INTL", Kind: KindText, Category: "Misc", Intl: true},
|
||||
{Name: "NOTES", Kind: KindText, Category: "Misc", Promoted: true},
|
||||
{Name: "NOTES_INTL", Kind: KindText, Category: "Misc", Intl: true},
|
||||
{Name: "PUBLIC_KEY", Kind: KindText, Category: "Misc"},
|
||||
{Name: "VE_PROV", Kind: KindText, Category: "Misc", Deprecated: true},
|
||||
|
||||
// ── My station / operator ───────────────────────────────────────
|
||||
{Name: "STATION_CALLSIGN", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "OPERATOR", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_NAME", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_NAME_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_GRIDSQUARE", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_GRIDSQUARE_EXT", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_VUCC_GRIDS", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_COUNTRY", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_COUNTRY_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_STATE", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||
{Name: "MY_CNTY", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||
{Name: "MY_CNTY_ALT", Kind: KindEnum, Category: "My station"},
|
||||
{Name: "MY_DXCC", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||
{Name: "MY_CQ_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
|
||||
{Name: "MY_ITU_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
|
||||
{Name: "MY_IOTA", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_IOTA_ISLAND_ID", Kind: KindText, Category: "My station"},
|
||||
{Name: "MY_SOTA_REF", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_POTA_REF", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_WWFF_REF", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_SIG", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_SIG_INFO", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_SIG_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_SIG_INFO_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_LAT", Kind: KindLoc, Category: "My station", Promoted: true},
|
||||
{Name: "MY_LON", Kind: KindLoc, Category: "My station", Promoted: true},
|
||||
{Name: "MY_ALTITUDE", Kind: KindNumber, Category: "My station"},
|
||||
{Name: "MY_STREET", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_STREET_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_CITY", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_CITY_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_POSTAL_CODE", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_POSTAL_CODE_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_RIG", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_RIG_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_ANTENNA", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_ANTENNA_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||
{Name: "MY_ARRL_SECT", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||
{Name: "MY_USACA_COUNTIES", Kind: KindText, Category: "My station"},
|
||||
{Name: "MY_DARC_DOK", Kind: KindText, Category: "My station", Promoted: true},
|
||||
{Name: "MY_FISTS", Kind: KindNumber, Category: "My station"},
|
||||
{Name: "MY_MORSE_KEY_TYPE", Kind: KindEnum, Category: "My station"},
|
||||
{Name: "MY_MORSE_KEY_INFO", Kind: KindText, Category: "My station"},
|
||||
}
|
||||
|
||||
// fieldIndex maps an uppercase ADIF tag to its definition for O(1) lookup.
|
||||
var fieldIndex = func() map[string]FieldDef {
|
||||
m := make(map[string]FieldDef, len(Fields))
|
||||
for _, f := range Fields {
|
||||
m[f.Name] = f
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// IsStandardField reports whether tag (any case) is a defined ADIF 3.1.7
|
||||
// field. APP_* and USERDEF tags are non-standard and return false.
|
||||
func IsStandardField(tag string) bool {
|
||||
_, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// LookupField returns the definition for a tag (any case), ok=false if unknown.
|
||||
func LookupField(tag string) (FieldDef, bool) {
|
||||
f, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
|
||||
return f, ok
|
||||
}
|
||||
+150
-1
@@ -20,6 +20,7 @@ import (
|
||||
type ImportResult struct {
|
||||
Total int `json:"total"` // records found in the file
|
||||
Imported int `json:"imported"` // successfully inserted
|
||||
Updated int `json:"updated"` // existing QSOs refreshed (update-duplicates mode)
|
||||
Skipped int `json:"skipped"` // dropped (missing required fields)
|
||||
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
||||
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
||||
@@ -36,6 +37,19 @@ type Importer struct {
|
||||
Repo *qso.Repo
|
||||
BatchSize int // 0 → 500
|
||||
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
||||
// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
|
||||
// a record matching an existing QSO MERGES its non-empty fields onto
|
||||
// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
|
||||
// being skipped or re-inserted.
|
||||
UpdateDuplicates bool
|
||||
// Enrich, when set, is called on each parsed QSO before dedup/insert.
|
||||
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||
// source file (common with contest loggers) is corrected on the way in.
|
||||
Enrich func(*qso.QSO)
|
||||
// OnProgress, when set, is called periodically with (processed, total)
|
||||
// record counts so the UI can show a progress bar. total is an estimate
|
||||
// from counting <EOR> tags up front.
|
||||
OnProgress func(processed, total int)
|
||||
}
|
||||
|
||||
// ImportFile reads the file at path and imports it into the repo. The
|
||||
@@ -62,6 +76,14 @@ func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult,
|
||||
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
||||
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
||||
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
||||
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
|
||||
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
|
||||
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
|
||||
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
|
||||
func ValueDecoderFor(data []byte) func([]byte) string {
|
||||
return pickValueDecoder(data)
|
||||
}
|
||||
|
||||
func pickValueDecoder(data []byte) func([]byte) string {
|
||||
if utf8.Valid(data) {
|
||||
return nil // identity
|
||||
@@ -97,6 +119,15 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
res := ImportResult{}
|
||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||
|
||||
// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
|
||||
// the UI progress bar has a denominator. Cheap single scan.
|
||||
total := countEOR(data)
|
||||
reportProgress := func(force bool) {
|
||||
if im.OnProgress != nil && (force || res.Total%200 == 0) {
|
||||
im.OnProgress(res.Total, total)
|
||||
}
|
||||
}
|
||||
|
||||
// One upfront query for every existing dedup key — cheaper than N
|
||||
// per-record EXISTS calls. The same map gets new keys appended as we
|
||||
// import so duplicates inside the file are caught too. Loaded
|
||||
@@ -107,6 +138,16 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
return res, fmt.Errorf("load dedupe keys: %w", err)
|
||||
}
|
||||
|
||||
// Update-duplicates mode needs the existing row's ID per key so it can
|
||||
// fetch, merge and write it back. Loaded only when needed (extra query).
|
||||
var keyIDs map[string]int64
|
||||
if im.UpdateDuplicates {
|
||||
keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("load dedupe ids: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
flush := func() error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
@@ -119,6 +160,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
|
||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||
res.Total++
|
||||
reportProgress(false)
|
||||
q, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
@@ -128,6 +170,9 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if im.Enrich != nil {
|
||||
im.Enrich(&q)
|
||||
}
|
||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||
if _, dup := seen[key]; dup {
|
||||
res.Duplicates++
|
||||
@@ -138,6 +183,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
||||
q.Band, q.Mode))
|
||||
}
|
||||
if im.UpdateDuplicates {
|
||||
if id, ok := keyIDs[key]; ok {
|
||||
existing, gerr := im.Repo.GetByID(ctx, id)
|
||||
if gerr != nil {
|
||||
if len(res.Errors) < maxErrors {
|
||||
res.Errors = append(res.Errors,
|
||||
fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
qso.MergeNonZero(&existing, q)
|
||||
if uerr := im.Repo.Update(ctx, existing); uerr != nil {
|
||||
if len(res.Errors) < maxErrors {
|
||||
res.Errors = append(res.Errors,
|
||||
fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
res.Updated++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if im.SkipDuplicates {
|
||||
return nil
|
||||
}
|
||||
@@ -159,9 +226,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
if err := flush(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reportProgress(true) // final 100%
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// countEOR estimates the record count by counting case-insensitive <EOR>
|
||||
// tags. Used only to give the import progress bar a denominator.
|
||||
func countEOR(data []byte) int {
|
||||
n := 0
|
||||
for i := 0; i+4 <= len(data); i++ {
|
||||
if data[i] != '<' {
|
||||
continue
|
||||
}
|
||||
if (data[i+1] == 'e' || data[i+1] == 'E') &&
|
||||
(data[i+2] == 'o' || data[i+2] == 'O') &&
|
||||
(data[i+3] == 'r' || data[i+3] == 'R') &&
|
||||
(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
||||
// Anything not in this set ends up in Extras.
|
||||
var adifPromoted = stringSet(
|
||||
@@ -178,12 +264,13 @@ var adifPromoted = stringSet(
|
||||
"age", "lat", "lon", "rig", "ant",
|
||||
// QSL
|
||||
"qsl_sent", "qsl_rcvd",
|
||||
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
|
||||
"qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
|
||||
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
||||
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
@@ -197,6 +284,12 @@ var adifPromoted = stringSet(
|
||||
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
|
||||
// Misc
|
||||
"tx_pwr", "comment", "notes",
|
||||
// ADIF 3.1.7 additional promoted fields
|
||||
"sig", "sig_info", "my_sig", "my_sig_info", "wwff_ref", "my_wwff_ref",
|
||||
"distance", "rx_pwr", "a_index", "k_index", "sfi",
|
||||
"skcc", "fists", "ten_ten", "contacted_op", "eq_call", "pfx", "my_name", "class",
|
||||
"darc_dok", "my_darc_dok", "region", "silent_key", "swl", "qso_complete", "qso_random",
|
||||
"credit_granted", "credit_submitted", "my_arrl_sect", "my_vucc_grids",
|
||||
)
|
||||
|
||||
func stringSet(items ...string) map[string]struct{} {
|
||||
@@ -252,6 +345,15 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
|
||||
q.FreqRXHz = &hz
|
||||
}
|
||||
// RX defaults to TX when the ADIF omits split info: an empty BAND_RX /
|
||||
// FREQ_RX means the contact wasn't cross-band/split, so RX = TX.
|
||||
if q.BandRX == "" {
|
||||
q.BandRX = q.Band
|
||||
}
|
||||
if q.FreqRXHz == nil && q.FreqHz != nil {
|
||||
v := *q.FreqHz
|
||||
q.FreqRXHz = &v
|
||||
}
|
||||
|
||||
q.RSTSent = rec["rst_sent"]
|
||||
q.RSTRcvd = rec["rst_rcvd"]
|
||||
@@ -299,6 +401,9 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.QSLSentDate = rec["qslsdate"]
|
||||
q.QSLRcvdDate = rec["qslrdate"]
|
||||
q.QSLVia = rec["qsl_via"]
|
||||
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
|
||||
q.QSLVia = rec["qsl_sent_via"]
|
||||
}
|
||||
q.QSLMsg = rec["qslmsg"]
|
||||
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
||||
q.LOTWSent = rec["lotw_qsl_sent"]
|
||||
@@ -315,6 +420,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
|
||||
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
|
||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||
|
||||
// Contest
|
||||
q.ContestID = rec["contest_id"]
|
||||
@@ -381,6 +488,48 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.Comment = rec["comment"]
|
||||
q.Notes = rec["notes"]
|
||||
|
||||
// ADIF 3.1.7 additional promoted fields
|
||||
q.SIG = rec["sig"]
|
||||
q.SIGInfo = rec["sig_info"]
|
||||
q.MySIG = rec["my_sig"]
|
||||
q.MySIGInfo = rec["my_sig_info"]
|
||||
q.WWFFRef = strings.ToUpper(rec["wwff_ref"])
|
||||
q.MyWWFFRef = strings.ToUpper(rec["my_wwff_ref"])
|
||||
if v, ok := parseFloat(rec["distance"]); ok {
|
||||
q.Distance = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["rx_pwr"]); ok {
|
||||
q.RXPower = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["a_index"]); ok {
|
||||
q.AIndex = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["k_index"]); ok {
|
||||
q.KIndex = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["sfi"]); ok {
|
||||
q.SFI = &v
|
||||
}
|
||||
q.SKCC = rec["skcc"]
|
||||
q.FISTS = rec["fists"]
|
||||
q.TenTen = rec["ten_ten"]
|
||||
q.ContactedOp = strings.ToUpper(rec["contacted_op"])
|
||||
q.EqCall = strings.ToUpper(rec["eq_call"])
|
||||
q.PFX = strings.ToUpper(rec["pfx"])
|
||||
q.MyName = rec["my_name"]
|
||||
q.Class = rec["class"]
|
||||
q.DarcDOK = rec["darc_dok"]
|
||||
q.MyDarcDOK = rec["my_darc_dok"]
|
||||
q.Region = rec["region"]
|
||||
q.SilentKey = strings.ToUpper(rec["silent_key"])
|
||||
q.SWL = strings.ToUpper(rec["swl"])
|
||||
q.QSOComplete = rec["qso_complete"]
|
||||
q.QSORandom = strings.ToUpper(rec["qso_random"])
|
||||
q.CreditGranted = rec["credit_granted"]
|
||||
q.CreditSubmitted = rec["credit_submitted"]
|
||||
q.MyARRLSect = strings.ToUpper(rec["my_arrl_sect"])
|
||||
q.MyVUCCGrids = strings.ToUpper(rec["my_vucc_grids"])
|
||||
|
||||
// Everything else lands in extras (uppercased ADIF names).
|
||||
var extras map[string]string
|
||||
for k, v := range rec {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Record is a single ADIF record. Keys are lowercased field names.
|
||||
@@ -83,6 +84,17 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
if _, err := io.ReadFull(br, val); err != nil {
|
||||
return fmt.Errorf("read field %s: %w", name, err)
|
||||
}
|
||||
// Repair character-count lengths. The ADIF spec says LENGTH is a
|
||||
// byte count, but some loggers (notably Log4OM's UDP "ADIF
|
||||
// message") write the CHARACTER count instead. For UTF-8 values
|
||||
// with accented chars that truncates mid-rune — e.g. "<QTH:7>
|
||||
// Tóalmás" is 9 bytes but says 7, leaving an orphan byte that
|
||||
// renders as "Tóalm�". When we're in UTF-8 mode (no Windows-1252
|
||||
// decoder) and the naive byte read isn't valid UTF-8, keep reading
|
||||
// until the value holds `length` whole runes (or the next tag).
|
||||
if decodeValue == nil && !utf8.Valid(val) {
|
||||
val = extendToRunes(br, val, length)
|
||||
}
|
||||
if headerDone && name != "" {
|
||||
if decodeValue != nil {
|
||||
rec[name] = decodeValue(val)
|
||||
@@ -94,6 +106,37 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
}
|
||||
}
|
||||
|
||||
// extendToRunes recovers a value whose declared length was a character count
|
||||
// rather than a byte count. `have` holds the first `wantRunes` BYTES of the
|
||||
// value, which turned out to be invalid UTF-8 (a multibyte rune was cut). We
|
||||
// append bytes from br until the value holds `wantRunes` complete runes — or
|
||||
// until the next '<' (start of the following tag) / EOF, so we never cross
|
||||
// into another field. Capped so a genuinely-corrupt value can't run away.
|
||||
func extendToRunes(br *bufio.Reader, have []byte, wantRunes int) []byte {
|
||||
const maxExtra = 8 // at most ~4 extra bytes/rune for the few cut runes
|
||||
limit := len(have) + maxExtra*wantRunes + maxExtra
|
||||
for len(have) < limit {
|
||||
// Stop only when the value is complete UTF-8 (no partial trailing
|
||||
// rune) AND holds enough runes. Checking utf8.RuneCount alone is a
|
||||
// trap: a trailing orphan lead byte (e.g. the D0 of a cut Cyrillic
|
||||
// "а") counts as one rune, so the loop would stop one continuation
|
||||
// byte short → "Чайк�". Requiring utf8.Valid forces us to read it.
|
||||
if utf8.Valid(have) && utf8.RuneCount(have) >= wantRunes {
|
||||
break
|
||||
}
|
||||
b, err := br.ReadByte()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if b == '<' {
|
||||
_ = br.UnreadByte() // belongs to the next tag — leave it
|
||||
break
|
||||
}
|
||||
have = append(have, b)
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
||||
// name is lowercased; length is 0 for control tags or when missing.
|
||||
func parseSpec(spec string) (name string, length int) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// TestPromotedFieldsRoundTrip writes a QSO carrying the ADIF 3.1.7 promoted
|
||||
// fields, parses it back, and checks they survive — guarding the export
|
||||
// writeRecord ↔ import recordToQSO field-name mapping against typos.
|
||||
func TestPromotedFieldsRoundTrip(t *testing.T) {
|
||||
dist := 1234.5
|
||||
rxp := 5.0
|
||||
a := 12.0
|
||||
in := qso.QSO{
|
||||
Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
|
||||
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
|
||||
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
|
||||
WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
|
||||
Distance: &dist, RXPower: &rxp, AIndex: &a,
|
||||
SKCC: "12345S", FISTS: "999", TenTen: "55555",
|
||||
ContactedOp: "EA8XYZ", EqCall: "EA8OLD", PFX: "EA8", MyName: "Greg",
|
||||
Class: "1A", DarcDOK: "A01", MyDarcDOK: "B02", Region: "IV",
|
||||
SilentKey: "N", SWL: "N", QSOComplete: "Y", QSORandom: "Y",
|
||||
CreditGranted: "DXCC", CreditSubmitted: "WAS",
|
||||
MyARRLSect: "EMA", MyVUCCGrids: "FN20,FN21",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
bw := bufio.NewWriter(&buf)
|
||||
bw.WriteString("<EOH>\n")
|
||||
writeRecord(bw, in, true)
|
||||
bw.Flush()
|
||||
|
||||
var rec Record
|
||||
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
t.Fatal("recordToQSO returned !ok")
|
||||
}
|
||||
|
||||
checks := map[string]struct{ got, want string }{
|
||||
"SIG": {out.SIG, in.SIG},
|
||||
"SIG_INFO": {out.SIGInfo, in.SIGInfo},
|
||||
"MY_SIG": {out.MySIG, in.MySIG},
|
||||
"MY_SIG_INFO": {out.MySIGInfo, in.MySIGInfo},
|
||||
"WWFF_REF": {out.WWFFRef, in.WWFFRef},
|
||||
"MY_WWFF_REF": {out.MyWWFFRef, in.MyWWFFRef},
|
||||
"SKCC": {out.SKCC, in.SKCC},
|
||||
"FISTS": {out.FISTS, in.FISTS},
|
||||
"TEN_TEN": {out.TenTen, in.TenTen},
|
||||
"CONTACTED_OP": {out.ContactedOp, in.ContactedOp},
|
||||
"EQ_CALL": {out.EqCall, in.EqCall},
|
||||
"PFX": {out.PFX, in.PFX},
|
||||
"MY_NAME": {out.MyName, in.MyName},
|
||||
"CLASS": {out.Class, in.Class},
|
||||
"DARC_DOK": {out.DarcDOK, in.DarcDOK},
|
||||
"MY_DARC_DOK": {out.MyDarcDOK, in.MyDarcDOK},
|
||||
"REGION": {out.Region, in.Region},
|
||||
"SILENT_KEY": {out.SilentKey, in.SilentKey},
|
||||
"SWL": {out.SWL, in.SWL},
|
||||
"QSO_COMPLETE": {out.QSOComplete, in.QSOComplete},
|
||||
"QSO_RANDOM": {out.QSORandom, in.QSORandom},
|
||||
"CREDIT_GRANTED": {out.CreditGranted, in.CreditGranted},
|
||||
"CREDIT_SUBMITTED": {out.CreditSubmitted, in.CreditSubmitted},
|
||||
"MY_ARRL_SECT": {out.MyARRLSect, in.MyARRLSect},
|
||||
"MY_VUCC_GRIDS": {out.MyVUCCGrids, in.MyVUCCGrids},
|
||||
}
|
||||
for tag, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s round-trip = %q, want %q", tag, c.got, c.want)
|
||||
}
|
||||
}
|
||||
if out.Distance == nil || *out.Distance != dist {
|
||||
t.Errorf("DISTANCE round-trip = %v, want %v", out.Distance, dist)
|
||||
}
|
||||
if out.RXPower == nil || *out.RXPower != rxp {
|
||||
t.Errorf("RX_PWR round-trip = %v, want %v", out.RXPower, rxp)
|
||||
}
|
||||
if out.AIndex == nil || *out.AIndex != a {
|
||||
t.Errorf("A_INDEX round-trip = %v, want %v", out.AIndex, a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandardExportDropsNonStandard verifies that standard mode strips
|
||||
// vendor/APP tags while full mode keeps them.
|
||||
func TestStandardExportDropsNonStandard(t *testing.T) {
|
||||
q := qso.QSO{
|
||||
Callsign: "F4BPO", Band: "20m", Mode: "CW",
|
||||
Extras: map[string]string{
|
||||
"APP_LOG4OM_FOO": "x",
|
||||
"DARC_DOK": "A01", // standard → kept in both
|
||||
"MY_VENDOR_TAG": "y", // non-standard → dropped in standard mode
|
||||
},
|
||||
}
|
||||
|
||||
standard := renderRecord(q, false)
|
||||
if strings.Contains(standard, "APP_LOG4OM_FOO") || strings.Contains(standard, "MY_VENDOR_TAG") {
|
||||
t.Errorf("standard export should drop non-standard tags:\n%s", standard)
|
||||
}
|
||||
|
||||
full := renderRecord(q, true)
|
||||
if !strings.Contains(full, "APP_LOG4OM_FOO") || !strings.Contains(full, "MY_VENDOR_TAG") {
|
||||
t.Errorf("full export should keep all extras:\n%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
func renderRecord(q qso.QSO, includeApp bool) string {
|
||||
var buf bytes.Buffer
|
||||
bw := bufio.NewWriter(&buf)
|
||||
writeRecord(bw, q, includeApp)
|
||||
bw.Flush()
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Package alerts evaluates incoming DX-cluster spots against user-defined rules
|
||||
// and reports which rules fire, so the app can notify the operator (sound /
|
||||
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
|
||||
// Management. Rules are persisted as JSON (global, machine-local).
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rule is one alert definition. Every filter dimension is optional: an empty
|
||||
// list/string matches ANY value, and the dimensions are ANDed together, so a
|
||||
// rule with Countries=[France] and Bands=[20m] fires only for French stations
|
||||
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// DX filters.
|
||||
Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call
|
||||
Countries []string `json:"countries,omitempty"` // DXCC entity names
|
||||
Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA
|
||||
|
||||
// Band / mode filters.
|
||||
Bands []string `json:"bands,omitempty"`
|
||||
Modes []string `json:"modes,omitempty"`
|
||||
|
||||
// Spotter (origin) filters.
|
||||
SpotterCall string `json:"spotter_call,omitempty"` // wildcard
|
||||
SpotterContinents []string `json:"spotter_continents,omitempty"`
|
||||
SpotterCountries []string `json:"spotter_countries,omitempty"`
|
||||
|
||||
// Actions.
|
||||
Sound bool `json:"sound"`
|
||||
Visual bool `json:"visual"`
|
||||
Email bool `json:"email"`
|
||||
|
||||
// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
|
||||
// once per session, -1 = always, >0 = that many minutes.
|
||||
AgainAfterMin int `json:"again_after_min"`
|
||||
|
||||
// Skip a spot whose DX call is already worked on this band+mode.
|
||||
SkipWorked bool `json:"skip_worked"`
|
||||
}
|
||||
|
||||
// Spot is the subset of a cluster spot the engine matches against.
|
||||
type Spot struct {
|
||||
DXCall string
|
||||
Band string
|
||||
Mode string // inferred (see InferMode)
|
||||
Country string // DXCC entity name
|
||||
Continent string
|
||||
Spotter string
|
||||
SpotterCountry string
|
||||
SpotterContinent string
|
||||
}
|
||||
|
||||
// Store persists the rule set as a JSON file.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
rules []Rule
|
||||
// last fired time per (ruleID|call), for the AgainAfter throttle.
|
||||
lastFired map[string]time.Time
|
||||
}
|
||||
|
||||
// Open loads the store (empty when the file is absent).
|
||||
func Open(path string) (*Store, error) {
|
||||
s := &Store{path: path, lastFired: map[string]time.Time{}}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) save() error {
|
||||
b, err := json.MarshalIndent(s.rules, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// List returns a copy of all rules.
|
||||
func (s *Store) List() []Rule {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Rule, len(s.rules))
|
||||
copy(out, s.rules)
|
||||
return out
|
||||
}
|
||||
|
||||
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
|
||||
|
||||
// Save upserts a rule (creating an id when empty) and returns it.
|
||||
func (s *Store) Save(r Rule) (Rule, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if strings.TrimSpace(r.ID) == "" {
|
||||
r.ID = newID()
|
||||
s.rules = append(s.rules, r)
|
||||
} else {
|
||||
found := false
|
||||
for i := range s.rules {
|
||||
if s.rules[i].ID == r.ID {
|
||||
s.rules[i] = r
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.rules = append(s.rules, r)
|
||||
}
|
||||
}
|
||||
return r, s.save()
|
||||
}
|
||||
|
||||
// Delete removes a rule by id.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.rules {
|
||||
if s.rules[i].ID == id {
|
||||
s.rules = append(s.rules[:i], s.rules[i+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match is a rule that fired for a spot (returned by Evaluate).
|
||||
type Match struct {
|
||||
Rule Rule
|
||||
Spot Spot
|
||||
}
|
||||
|
||||
// Evaluate returns every enabled rule that matches the spot AND isn't currently
|
||||
// throttled. It records the fire time for matched rules so the AgainAfter window
|
||||
// is honoured. workedFn (may be nil) reports whether the DX call is already
|
||||
// worked on this band+mode — used by rules with SkipWorked.
|
||||
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []Match
|
||||
for _, r := range s.rules {
|
||||
if !r.Enabled || !ruleMatches(r, sp) {
|
||||
continue
|
||||
}
|
||||
if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
|
||||
continue
|
||||
}
|
||||
key := r.ID + "|" + strings.ToUpper(sp.DXCall)
|
||||
if r.AgainAfterMin >= 0 {
|
||||
last, seen := s.lastFired[key]
|
||||
if seen {
|
||||
if r.AgainAfterMin == 0 {
|
||||
continue // once per session
|
||||
}
|
||||
if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
s.lastFired[key] = now
|
||||
out = append(out, Match{Rule: r, Spot: sp})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ruleMatches reports whether every specified filter dimension matches the spot.
|
||||
func ruleMatches(r Rule, sp Spot) bool {
|
||||
if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
|
||||
return false
|
||||
}
|
||||
if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
|
||||
return false
|
||||
}
|
||||
if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
|
||||
return false
|
||||
}
|
||||
if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
|
||||
return false
|
||||
}
|
||||
if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
|
||||
return false
|
||||
}
|
||||
if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
|
||||
return false
|
||||
}
|
||||
if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsFold(list []string, v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyWildcard(patterns []string, v string) bool {
|
||||
for _, p := range patterns {
|
||||
if matchWildcard(p, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchWildcard does a case-insensitive full-string match where '*' matches any
|
||||
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
|
||||
func matchWildcard(pattern, v string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
v = strings.TrimSpace(v)
|
||||
if pattern == "" {
|
||||
return true
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("(?i)^")
|
||||
for _, r := range pattern {
|
||||
switch r {
|
||||
case '*':
|
||||
b.WriteString(".*")
|
||||
case '?':
|
||||
b.WriteString(".")
|
||||
default:
|
||||
b.WriteString(regexp.QuoteMeta(string(r)))
|
||||
}
|
||||
}
|
||||
b.WriteString("$")
|
||||
re, err := regexp.Compile(b.String())
|
||||
if err != nil {
|
||||
return strings.EqualFold(pattern, v)
|
||||
}
|
||||
return re.MatchString(v)
|
||||
}
|
||||
|
||||
// InferMode guesses a spot's mode from its comment and frequency. Cluster spots
|
||||
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
|
||||
// back to the digital watering holes and the band-plan CW/phone split.
|
||||
func InferMode(comment string, freqHz int64) string {
|
||||
c := strings.ToUpper(comment)
|
||||
switch {
|
||||
case strings.Contains(c, "FT8"):
|
||||
return "FT8"
|
||||
case strings.Contains(c, "FT4"):
|
||||
return "FT4"
|
||||
case strings.Contains(c, "RTTY"):
|
||||
return "RTTY"
|
||||
case strings.Contains(c, "PSK"):
|
||||
return "PSK"
|
||||
case strings.Contains(c, "JS8"):
|
||||
return "JS8"
|
||||
case strings.Contains(c, "CW"):
|
||||
return "CW"
|
||||
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
|
||||
return "SSB"
|
||||
}
|
||||
khz := float64(freqHz) / 1000
|
||||
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
|
||||
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
|
||||
if khz >= f-1 && khz <= f+3 {
|
||||
return "FT8"
|
||||
}
|
||||
}
|
||||
// Band-plan CW segments (bottom of each band).
|
||||
switch {
|
||||
case khz >= 1810 && khz <= 1840,
|
||||
khz >= 3500 && khz <= 3570,
|
||||
khz >= 7000 && khz <= 7040,
|
||||
khz >= 10100 && khz <= 10130,
|
||||
khz >= 14000 && khz <= 14070,
|
||||
khz >= 18068 && khz <= 18095,
|
||||
khz >= 21000 && khz <= 21070,
|
||||
khz >= 24890 && khz <= 24910,
|
||||
khz >= 28000 && khz <= 28070:
|
||||
return "CW"
|
||||
}
|
||||
return "SSB"
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP
|
||||
// text API (default port 9007). On connect the device sends a banner line
|
||||
// (e.g. "V4.1.16 AG"); commands are "C<seq>|<command>\r" and the device replies
|
||||
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
|
||||
// "S<0>|<message>" status pushes once you subscribe with "sub port/antenna".
|
||||
//
|
||||
// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used
|
||||
// by pre-v4 firmware and is NOT what v4 speaks.)
|
||||
package antgenius
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 9007
|
||||
dialTimeout = 5 * time.Second
|
||||
writeTimeout = 3 * time.Second
|
||||
readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead
|
||||
keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// Antenna is one configured antenna (index + name as stored on the device).
|
||||
// Bands is the device's band bitmask for the antenna (which bands it covers);
|
||||
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
|
||||
// the native 4O3A app.
|
||||
type Antenna struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Bands int `json:"bands"`
|
||||
}
|
||||
|
||||
// Status is the snapshot the UI renders.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Host string `json:"host,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
PortA int `json:"port_a"` // active antenna index on port A (0 = none)
|
||||
PortB int `json:"port_b"` // active antenna index on port B
|
||||
TxA bool `json:"tx_a"` // port A is transmitting
|
||||
TxB bool `json:"tx_b"` // port B is transmitting
|
||||
Antennas []Antenna `json:"antennas"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
password string // remote-access password; sent as "login <pw>" when the device requires AUTH
|
||||
|
||||
mu sync.Mutex // guards conn + writes
|
||||
conn net.Conn
|
||||
|
||||
// Auth handshake state (touched only on the runLoop/readLoop goroutine).
|
||||
awaitingAuth bool
|
||||
authTries int
|
||||
ready atomic.Bool // init commands sent → keepalive may run
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(host string, port int, password string) *Client {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
password: strings.TrimSpace(password),
|
||||
stop: make(chan struct{}),
|
||||
antennas: map[int]string{},
|
||||
antBands: map[int]int{},
|
||||
status: Status{Host: host},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
c.running = true
|
||||
go c.runLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
c.running = false
|
||||
close(c.stop)
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() Status {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
func (c *Client) setStatus(fn func(*Status)) {
|
||||
c.statusMu.Lock()
|
||||
fn(&c.status)
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets
|
||||
// the port to "None"). We set both RX and TX antennas and force manual mode so
|
||||
// the choice sticks regardless of the device's auto band-following.
|
||||
func (c *Client) Activate(port, antenna int) error {
|
||||
if port != 1 && port != 2 {
|
||||
return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port)
|
||||
}
|
||||
if antenna < 0 {
|
||||
return fmt.Errorf("antgenius: invalid antenna %d", antenna)
|
||||
}
|
||||
// Set only rxant (like the reference ShackMaster client): the AG mirrors it
|
||||
// to the TX antenna automatically. Forcing txant too can be rejected on the
|
||||
// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
|
||||
// selection and deselection.
|
||||
if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ask for the new port state so the snapshot reflects it promptly (the
|
||||
// subscription also pushes it, but this makes the change deterministic).
|
||||
_ = c.send(fmt.Sprintf("port get %d", port))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) runLoop() {
|
||||
for {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
|
||||
if err != nil {
|
||||
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||
if c.sleep(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
||||
applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr())
|
||||
|
||||
// Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a
|
||||
// password we send "auth code=<pw>" and only send the subscribe/get commands
|
||||
// once it's accepted (retrying on FF); without AUTH they go out immediately.
|
||||
c.awaitingAuth, c.authTries = false, 0
|
||||
c.ready.Store(false)
|
||||
|
||||
done := make(chan struct{})
|
||||
go c.keepalive(conn, done)
|
||||
err = c.readLoop(conn) // blocks until the link errors
|
||||
close(done)
|
||||
|
||||
c.mu.Lock()
|
||||
if c.conn == conn {
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
conn.Close()
|
||||
c.setStatus(func(s *Status) {
|
||||
s.Connected = false
|
||||
if err != nil {
|
||||
s.LastError = "read: " + err.Error()
|
||||
}
|
||||
})
|
||||
if c.sleep(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keepalive periodically re-reads a port so an idle-but-dead link is detected
|
||||
// (the read loop's idle timeout fires if these stop producing replies).
|
||||
func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
|
||||
t := time.NewTicker(keepaliveEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
if !c.ready.Load() {
|
||||
continue // not authenticated / subscribed yet
|
||||
}
|
||||
_ = c.send("port get 1")
|
||||
_ = c.send("port get 2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendInit subscribes to live updates and pulls the initial state. Called once
|
||||
// the link is ready (authenticated, or immediately when no AUTH is required).
|
||||
// Command set/order mirror a known-working Node-RED v4 client (WA9WUD).
|
||||
func (c *Client) sendInit() {
|
||||
c.ready.Store(true)
|
||||
for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} {
|
||||
if err := c.send(cmd); err != nil {
|
||||
applog.Printf("antgenius: init send %q failed: %v", cmd, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(conn net.Conn) error {
|
||||
r := bufio.NewReader(conn)
|
||||
var sb strings.Builder
|
||||
start := time.Now()
|
||||
lines, bytesRx := 0, 0
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
// Log where the link died: 0 lines after connect points at the device
|
||||
// refusing/closing (e.g. a client-slot limit or source-IP rejection —
|
||||
// common when reaching it remotely); an EOF after the banner/replies
|
||||
// points at something later in the exchange.
|
||||
applog.Printf("antgenius: read ended after %s — %d line(s), %d byte(s): %v",
|
||||
time.Since(start).Round(time.Millisecond), lines, bytesRx, err)
|
||||
return err
|
||||
}
|
||||
bytesRx++
|
||||
if b == '\r' || b == '\n' {
|
||||
if sb.Len() > 0 {
|
||||
lines++
|
||||
if lines <= 10 {
|
||||
applog.Printf("antgenius: rx[%d] %q", lines, sb.String())
|
||||
}
|
||||
c.handleLine(sb.String())
|
||||
sb.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(b)
|
||||
}
|
||||
}
|
||||
|
||||
// send writes a "C<seq>|<command>\r" line to the device.
|
||||
func (c *Client) send(command string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("antgenius: not connected")
|
||||
}
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
// The device only accepts the constant "C1|" sequence prefix for every
|
||||
// command (using incrementing sequence numbers makes it drop the link);
|
||||
// commands are LF-terminated.
|
||||
_, err := fmt.Fprintf(c.conn, "C1|%s\n", command)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleLine parses one response/status/banner line and updates the snapshot.
|
||||
func (c *Client) handleLine(line string) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
// Banner: "V4.1.16 AG" (LAN) or "V4.1.16 AG AUTH" (remote). It drives what we
|
||||
// send next — authenticate first when AUTH is announced, else go straight to
|
||||
// the subscribe/get commands.
|
||||
if line[0] == 'V' && strings.Contains(line, "AG") {
|
||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
|
||||
if strings.Contains(line, "AUTH") && c.password != "" {
|
||||
c.awaitingAuth, c.authTries = true, 1
|
||||
applog.Printf("antgenius: AUTH required — authenticating")
|
||||
_ = c.send("auth code=" + c.password)
|
||||
} else {
|
||||
if strings.Contains(line, "AUTH") {
|
||||
applog.Printf("antgenius: device requires AUTH but no remote password set (Settings → Antenna Genius)")
|
||||
}
|
||||
c.sendInit()
|
||||
}
|
||||
return
|
||||
}
|
||||
// R<seq>|<hex>|<message> or S<seq>|<message>
|
||||
var hexCode, msg string
|
||||
switch {
|
||||
case strings.HasPrefix(line, "R"):
|
||||
p := strings.SplitN(line, "|", 3)
|
||||
if len(p) >= 2 {
|
||||
hexCode = p[1]
|
||||
}
|
||||
if len(p) == 3 {
|
||||
msg = p[2]
|
||||
}
|
||||
case strings.HasPrefix(line, "S"):
|
||||
p := strings.SplitN(line, "|", 2)
|
||||
if len(p) == 2 {
|
||||
msg = p[1]
|
||||
}
|
||||
}
|
||||
msg = strings.TrimSpace(msg)
|
||||
// Auth result: the reply to "auth code=" carries an empty message; hex "0" =
|
||||
// accepted. The device rejects the FIRST attempt (R|FF) and accepts a retry,
|
||||
// so resend a few times before giving up.
|
||||
if c.awaitingAuth && strings.HasPrefix(line, "R") && msg == "" {
|
||||
switch {
|
||||
case hexCode == "0":
|
||||
c.awaitingAuth = false
|
||||
applog.Printf("antgenius: authenticated")
|
||||
c.sendInit()
|
||||
case c.authTries < 4:
|
||||
c.authTries++
|
||||
applog.Printf("antgenius: auth rejected (R|%s|) — retry %d", hexCode, c.authTries)
|
||||
_ = c.send("auth code=" + c.password)
|
||||
default:
|
||||
c.awaitingAuth = false
|
||||
applog.Printf("antgenius: auth failed after %d tries (R|%s|) — check the remote password", c.authTries, hexCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(msg, "antenna "):
|
||||
c.parseAntenna(msg)
|
||||
case strings.HasPrefix(msg, "port "):
|
||||
c.parsePort(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// parseAntenna handles "antenna <id> name=<name> tx=.. rx=.. inband=..".
|
||||
// The name may contain spaces, so it's extracted up to the " tx=" field.
|
||||
func (c *Client) parseAntenna(msg string) {
|
||||
fields := strings.Fields(msg)
|
||||
if len(fields) < 2 {
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if i := strings.Index(msg, "name="); i >= 0 {
|
||||
name = msg[i+len("name="):]
|
||||
if j := strings.Index(name, " tx="); j >= 0 {
|
||||
name = name[:j]
|
||||
}
|
||||
// The device stores spaces as underscores in names.
|
||||
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
||||
}
|
||||
// Band bitmask: which bands this antenna is configured for. The device sends
|
||||
// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
|
||||
// antennas (like the native app). Log the first few raw antenna lines so the
|
||||
// exact field name + bit values can be confirmed on real hardware.
|
||||
bands := kvInt(msg, "band")
|
||||
c.statusMu.Lock()
|
||||
if c.antRawN < 8 {
|
||||
c.antRawN++
|
||||
applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
|
||||
}
|
||||
if name != "" && !isPlaceholderName(name) {
|
||||
c.antennas[id] = name
|
||||
c.antBands[id] = bands
|
||||
} else {
|
||||
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
||||
delete(c.antBands, id)
|
||||
}
|
||||
c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
|
||||
c.status.Connected = true
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// parsePort handles "port <id> ... rxant=<n> txant=<n> ...". The active antenna
|
||||
// shown is the TX antenna, falling back to the RX antenna when TX is none.
|
||||
func (c *Client) parsePort(msg string) {
|
||||
fields := strings.Fields(msg)
|
||||
if len(fields) < 2 {
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(fields[1])
|
||||
if err != nil || (id != 1 && id != 2) {
|
||||
return
|
||||
}
|
||||
tx := kvInt(msg, "txant")
|
||||
rx := kvInt(msg, "rxant")
|
||||
active := tx
|
||||
if active == 0 {
|
||||
active = rx
|
||||
}
|
||||
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
||||
c.setStatus(func(s *Status) {
|
||||
s.Connected = true
|
||||
if id == 1 {
|
||||
s.PortA, s.TxA = active, txOn
|
||||
} else {
|
||||
s.PortB, s.TxB = active, txOn
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) sleep(d time.Duration) (stopped bool) {
|
||||
select {
|
||||
case <-c.stop:
|
||||
return true
|
||||
case <-time.After(d):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// kvInt extracts the integer value of a "key=<int>" token from a space-
|
||||
// separated string (returns 0 if absent).
|
||||
func kvInt(s, key string) int {
|
||||
i := strings.Index(s, key+"=")
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
v := s[i+len(key)+1:]
|
||||
if sp := strings.IndexByte(v, ' '); sp >= 0 {
|
||||
v = v[:sp]
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return n
|
||||
}
|
||||
|
||||
// isPlaceholderName reports whether name is an unconfigured-slot default like
|
||||
// "Antenna 4" / "antenna_5" (after underscores become spaces): the word
|
||||
// "antenna" followed by a number, which the UI shouldn't list.
|
||||
func isPlaceholderName(name string) bool {
|
||||
f := strings.Fields(strings.ToLower(name))
|
||||
if len(f) != 2 || f[0] != "antenna" {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(f[1])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
|
||||
out := make([]Antenna, 0, len(m))
|
||||
for idx, name := range m {
|
||||
out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package antgenius
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHandleAntennaList(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
// Names may contain spaces — must be captured up to " tx=".
|
||||
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
|
||||
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
||||
st := c.GetStatus()
|
||||
if len(st.Antennas) != 2 {
|
||||
t.Fatalf("got %d antennas, want 2: %+v", len(st.Antennas), st.Antennas)
|
||||
}
|
||||
if st.Antennas[0].Index != 1 || st.Antennas[0].Name != "UB VL2.3" {
|
||||
t.Errorf("antenna 1 = %+v", st.Antennas[0])
|
||||
}
|
||||
if st.Antennas[1].Name != "DX Commander" {
|
||||
t.Errorf("antenna 2 name = %q", st.Antennas[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
|
||||
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||
st := c.GetStatus()
|
||||
if len(st.Antennas) != 1 || st.Antennas[0].Name != "Hex Beam" {
|
||||
t.Fatalf("want only [Hex Beam], got %+v", st.Antennas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePortStatus(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
|
||||
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
||||
st := c.GetStatus()
|
||||
if st.PortA != 2 {
|
||||
t.Errorf("PortA = %d, want 2", st.PortA)
|
||||
}
|
||||
if st.PortB != 0 {
|
||||
t.Errorf("PortB = %d, want 0 (none)", st.PortB)
|
||||
}
|
||||
// A "port get" reply (R-line) must parse the same way.
|
||||
c.handleLine("R15|0|port 2 source=MANUAL band=3 rxant=5 txant=5 inband=0 inhibit=0")
|
||||
if st = c.GetStatus(); st.PortB != 5 {
|
||||
t.Errorf("PortB after port get = %d, want 5", st.PortB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortTxFallbackToRx(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
||||
if st := c.GetStatus(); st.PortA != 3 {
|
||||
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKvInt(t *testing.T) {
|
||||
s := "port 1 source=MANUAL band=6 rxant=2 txant=7 inhibit=0"
|
||||
if v := kvInt(s, "txant"); v != 7 {
|
||||
t.Errorf("txant = %d, want 7", v)
|
||||
}
|
||||
if v := kvInt(s, "rxant"); v != 2 {
|
||||
t.Errorf("rxant = %d, want 2", v)
|
||||
}
|
||||
if v := kvInt(s, "missing"); v != 0 {
|
||||
t.Errorf("missing = %d, want 0", v)
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,16 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
crashFile *os.File // kept open so the runtime can write a crash traceback to it
|
||||
)
|
||||
|
||||
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
||||
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
|
||||
file = f
|
||||
path = logPath
|
||||
|
||||
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
|
||||
// recover()s, or a runtime-fatal error like a concurrent map write, or a
|
||||
// Windows access violation routed through the Go signal handler) into a
|
||||
// dedicated file the runtime writes directly — so otherwise-silent process
|
||||
// deaths leave a stack we can read.
|
||||
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
|
||||
crashFile = cf
|
||||
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
|
||||
}
|
||||
|
||||
// Redirect log.Print* and the standard logger to the file too, so
|
||||
// any third-party output stays consistent.
|
||||
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package audio
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Codec converts between the wire payload of a network audio stream (Icom 50003)
|
||||
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
|
||||
// capture/render engine and the pcmRing use). It exists so the transport code
|
||||
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
|
||||
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
|
||||
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
|
||||
type Codec interface {
|
||||
// Name is a short label for logs/UI.
|
||||
Name() string
|
||||
// Decode turns one received audio payload into internal PCM. The returned
|
||||
// slice is freshly allocated (the caller may retain it).
|
||||
Decode(payload []byte) ([]byte, error)
|
||||
// Encode turns internal PCM into a payload to transmit.
|
||||
Encode(pcm []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
|
||||
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
|
||||
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
|
||||
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
|
||||
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
|
||||
//
|
||||
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
|
||||
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
|
||||
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
|
||||
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
|
||||
// in one place.
|
||||
type pcm16Codec struct{}
|
||||
|
||||
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
|
||||
func NewPCM16Codec() Codec { return pcm16Codec{} }
|
||||
|
||||
func (pcm16Codec) Name() string { return "pcm16" }
|
||||
|
||||
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
|
||||
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
|
||||
// packet — trim the stray byte rather than emit a half-sample click.
|
||||
n := len(payload) &^ 1
|
||||
if n != len(payload) {
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
|
||||
}
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, payload[:n])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
|
||||
out := make([]byte, len(pcm))
|
||||
copy(out, pcm)
|
||||
return out, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user