Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bd818ac4 | ||
|
|
d71d09cbb6 | ||
|
|
557fb162c3 | ||
|
|
77a2350240 | ||
|
|
2b3d118d84 | ||
|
|
638ffcb326 | ||
|
|
bf4fba484a | ||
|
|
3ea7f44fd1 | ||
|
|
384925f24a | ||
|
|
898323c49f | ||
|
|
48345a22fc | ||
|
|
b83d55cc32 | ||
|
|
76e4288b9e | ||
|
|
246ecf23ba | ||
|
|
1717677056 | ||
|
|
15159f38a5 | ||
|
|
6a28344e93 | ||
|
|
a14ade9277 | ||
|
|
513ab178ec | ||
|
|
e3aeeae616 | ||
|
|
a5cfecbf76 | ||
|
|
7d644d05cc | ||
|
|
75510a1161 | ||
|
|
1a0a349fb3 | ||
|
|
5e09b6a796 | ||
|
|
d6a2e84eed | ||
|
|
7b0ef0ba97 | ||
|
|
aa871a07b7 | ||
|
|
89584f173d | ||
|
|
a71e48f811 | ||
|
|
5c4f101402 | ||
|
|
ede9461010 | ||
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 | ||
|
|
c07b746d4b | ||
|
|
d128dabc19 | ||
|
|
2823f3e401 | ||
|
|
1070637c40 | ||
|
|
13de53772b | ||
|
|
6b04072e52 | ||
|
|
64b8ba7fdc | ||
|
|
a9ca50b2dd | ||
|
|
512b5fd35b | ||
|
|
77e3d011a0 | ||
|
|
b7dd8d4852 | ||
|
|
4c75680689 | ||
|
|
5aac28f564 | ||
|
|
c91c8c3b47 | ||
|
|
968da5488c | ||
|
|
1b5b0c2e90 | ||
|
|
0385aed760 | ||
|
|
24a5a0480d | ||
|
|
828f99b8ac | ||
|
|
9e5868b839 | ||
|
|
40d0ca57c3 | ||
|
|
aa537f9eea | ||
|
|
ea1bd2a66d | ||
|
|
1f54656256 | ||
|
|
54dd109288 | ||
|
|
2c1d7235f0 | ||
|
|
adf9844d1b | ||
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 | ||
|
|
9c62bf0152 | ||
|
|
1630c8fdf4 | ||
|
|
408791ddf5 | ||
|
|
3de47a8825 | ||
|
|
880ecdbbb5 | ||
|
|
615df0dc10 | ||
|
|
2194279602 | ||
|
|
51c4bda71a | ||
|
|
7d7d175ede | ||
|
|
be1ae76eb3 | ||
|
|
50157a25d3 | ||
|
|
fe1a77a54d | ||
|
|
b818a2d947 | ||
|
|
5c4ae0cfd7 | ||
|
|
69635a15bc | ||
|
|
78220e700f | ||
|
|
42a6b9c76a | ||
|
|
56affa4bed | ||
|
|
aa5af4fc75 | ||
|
|
46772e54fe | ||
|
|
a0cea352ff | ||
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 | ||
|
|
d30b305ff2 | ||
|
|
5abe4bd0c3 | ||
|
|
3ed9f29d9a | ||
|
|
04eaa91bd8 | ||
|
|
443698b507 | ||
|
|
db2908b3ef | ||
|
|
0838c2ec89 | ||
|
|
80c5fdc095 | ||
|
|
a1be0dfe68 | ||
|
|
ac4039393d | ||
|
|
bb53085c21 | ||
|
|
031dfa8f46 | ||
|
|
6322a425d8 | ||
|
|
9f08df1c39 | ||
|
|
aeeb658269 | ||
|
|
8e088576c7 | ||
|
|
eb2ff8ed59 | ||
|
|
dd3b51a2ae | ||
|
|
1a155e3627 | ||
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc | ||
|
|
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 |
@@ -39,9 +39,11 @@ desktop.ini
|
||||
hamlog.db*
|
||||
cty.dat
|
||||
cat.log
|
||||
*.adi
|
||||
|
||||
# --- Secrets ---
|
||||
.env
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
*.syso
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
# OpsLog
|
||||
|
||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
|
||||
</a>
|
||||
|
||||
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. Un **compteur de sélection** en direct, une bascule **Tout sélectionner /
|
||||
Tout désélectionner**, et une limite d'affichage qui garde le journal complet
|
||||
rapide **mais qui saute dès qu'un filtre est actif** (toutes les correspondances
|
||||
sont montrées, pas seulement la première page).
|
||||
- **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** (UDP), un **Rotator Genius 4O3A** (TCP natif) ou un **microHAM
|
||||
ARCO** contrôlé nativement — sans PstRotator — via le **réseau** ou l'**USB**
|
||||
avec son protocole Yaesu GS-232A.
|
||||
- **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). En option, tous les **modes numériques comptent comme un seul**
|
||||
(style DXCC) pour le coloriage nouveau/nouveau-créneau et les badges de la
|
||||
matrice des déjà-contactés (Réglages → Général).
|
||||
- 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 ; un clic remplit l'indicatif et **accorde
|
||||
la radio sur la bonne fréquence ET le bon mode**).
|
||||
- **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) :** sélecteurs d'**antenne** RX/TX et une bascule
|
||||
**DAX** (audio d'émission via DAX, pour WSJT-X & co), mode/seuil AGC, niveau
|
||||
audio, NB / WNB / NR / ANF, et — sur les radios **SmartSDR v4** (séries 8000 /
|
||||
Aurora) — les filtres DSP supplémentaires **NRL, NRS, NRF** (avec niveau) plus
|
||||
**RNN** (réduction de bruit par IA) et **ANFT** (notch automatique FFT),
|
||||
affichés automatiquement si la radio les supporte. **RIT / XIT** avec accord
|
||||
molette / ±.
|
||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||
- **Amplificateur :** la carte de commande suit l'ampli configuré, avec une liste
|
||||
déroulante pour le choisir quand **plusieurs amplificateurs** sont configurés
|
||||
(p. ex. deux SPE en parallèle). Voir *Amplis & commutateurs* ci-dessous.
|
||||
- **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), **FlexRadio CWX**
|
||||
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
|
||||
sans WinKeyer ni SmartCAT), **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
|
||||
|
||||
- **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
|
||||
Amplificateur est une liste ; p. ex. deux SPE en parallèle pour plus de
|
||||
puissance). La carte de chaque ampli apparaît sur l'onglet FlexRadio et dans
|
||||
**Station Control**, avec une liste déroulante pour choisir lequel afficher ; la
|
||||
barre de statut du bas porte **une pastille cliquable par ampli** (vert =
|
||||
OPERATE, orange = STANDBY, rouge = hors ligne). Supportés :
|
||||
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
||||
ventilateur et affichage des défauts.
|
||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
|
||||
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
|
||||
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
|
||||
ROS, courant PA, température, avertissements/alarmes).
|
||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) via **USB** ou le **réseau**
|
||||
(pont RS232-Ethernet) — operate/standby/arrêt et télémétrie en direct
|
||||
(puissance directe & réfléchie, ROS, température PA, bande, ventilateur,
|
||||
défauts). La mise en marche fonctionne via un câble série reliant les lignes
|
||||
DTR/RTS.
|
||||
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||
commutation A/B ancré.
|
||||
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
|
||||
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais** —
|
||||
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
|
||||
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
|
||||
antennes et accessoires de la station.
|
||||
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
|
||||
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
|
||||
relais, une fenêtre de fréquence ou un ensemble de bandes.
|
||||
|
||||
## 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.
|
||||
|
||||
## Statistiques
|
||||
|
||||
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
|
||||
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
|
||||
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
|
||||
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
|
||||
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
|
||||
**Table** qui reprend chaque graphique.
|
||||
|
||||
## 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**), la publication est **automatique** — aucun réglage à activer.
|
||||
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, et repasse *hors antenne* automatiquement 5 min après le dernier QSO
|
||||
logué. 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-les une
|
||||
par une ou tout le net d'un coup (**Logger tout le monde**) en utilisant la
|
||||
fréquence CAT. **Glisser-déposer** entre les deux listes (roster → on air démarre
|
||||
un QSO, on air → roster l'enregistre), et après chaque log la station on air
|
||||
suivante est sélectionnée automatiquement pour enchaîner les contacts.
|
||||
|
||||
## 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 et toutes les 5 minutes (et à
|
||||
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
|
||||
**Nouveautés** qui affiche le changelog (anglais / français) au premier
|
||||
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
|
||||
- **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,10 +1,17 @@
|
||||
# OpsLog
|
||||
|
||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||
</a>
|
||||
|
||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
||||
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||
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).
|
||||
|
||||
Developed by **F4BPO**.
|
||||
|
||||
@@ -30,10 +37,19 @@ Developed by **F4BPO**.
|
||||
- **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` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
||||
date overrides.
|
||||
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.
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||
fast **but is lifted while a filter is active** (every match is shown, not just
|
||||
the first page).
|
||||
- **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).
|
||||
|
||||
@@ -41,11 +57,15 @@ Developed by **F4BPO**.
|
||||
|
||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||
worked-before grid, or the **FlexRadio controls**.
|
||||
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.
|
||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||
using its Yaesu GS-232A protocol.
|
||||
- **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.
|
||||
@@ -57,16 +77,33 @@ Developed by **F4BPO**.
|
||||
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).
|
||||
rig, and a multi-band **Band Map** (panadapter-style strips). Optionally, all
|
||||
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and
|
||||
the worked-before matrix badges (Settings → General).
|
||||
- **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
|
||||
|
||||
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
||||
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
||||
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
||||
Flex display, click → fill the call).
|
||||
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
||||
from the frequency.
|
||||
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; a click fills the call and **tunes the rig to the right
|
||||
frequency AND mode**).
|
||||
- **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)
|
||||
|
||||
@@ -74,25 +111,99 @@ 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.
|
||||
- **Receive (active slice):** RX/TX **antenna** selectors and a **DAX** toggle
|
||||
(TX audio through DAX, for WSJT-X & co), AGC mode/threshold, audio level,
|
||||
NB / WNB / NR / ANF, and — on **SmartSDR v4** radios (8000 / Aurora series) —
|
||||
the extra DSP tools **NRL, NRS, NRF** (with level) plus **RNN** (AI noise
|
||||
reduction) and **ANFT** (FFT auto-notch), shown automatically when the radio
|
||||
supports them. **RIT / XIT** with wheel / ± tuning.
|
||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
||||
- **Amplifier:** the amp card follows whichever amplifier is configured, with a
|
||||
dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
|
||||
in parallel). See *Amplifiers & switches* below.
|
||||
- **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
|
||||
|
||||
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
||||
- **Digital Voice Keyer** (DVK) message playback.
|
||||
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
||||
audio in CW).
|
||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
|
||||
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
|
||||
SmartCAT needed), **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
|
||||
|
||||
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||
list; e.g. two SPEs run in parallel for more power). Each amp's control card
|
||||
appears on the FlexRadio tab and in **Station Control**, with a dropdown to
|
||||
choose which one it shows; the bottom status bar carries **one clickable chip
|
||||
per amp** (green = OPERATE, orange = STANDBY, red = offline). Supported:
|
||||
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||
selector and fault display.
|
||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||
Low / Mid / High output level, an output-power bar and live status (band,
|
||||
SWR, PA current, temperature, warnings/alarms).
|
||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||
over a serial cable that wires the DTR/RTS lines.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||
or a set of bands.
|
||||
|
||||
## QSL & awards
|
||||
|
||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
||||
manual reference assignment, live reference detection on call entry, and a
|
||||
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
||||
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).
|
||||
@@ -100,22 +211,71 @@ Shown only when the CAT backend is a FlexRadio:
|
||||
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.
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
|
||||
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
|
||||
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
|
||||
30-day / 12-month views), by operator and by continent. Date-range, per-operator
|
||||
and per-contest filters, and a **Table** view that mirrors every chart.
|
||||
|
||||
## 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
|
||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**),
|
||||
publishing is **automatic** — no setting to turn on. Each OpsLog instance
|
||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||
`live_status` table every ~15 s. A small PHP renderer
|
||||
`live_status` table every ~15 s, and drops back to *off air* automatically 5 min
|
||||
after the last logged QSO. 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 them individually or the
|
||||
whole net at once (**Log everyone**) using the CAT frequency. **Drag & drop**
|
||||
between the two lists (roster → on-air starts a QSO, on-air → roster logs it),
|
||||
and after each log the next on-air station is selected automatically so you can
|
||||
chain contacts.
|
||||
|
||||
## 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.
|
||||
- **Update check** at startup with a toast (toggleable).
|
||||
- **Backup:** optional database + ADIF backup at shutdown.
|
||||
- **Update check** at startup and every 5 minutes (and on opening Help → About),
|
||||
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
|
||||
(English / French) on the first launch after an update — reopenable any time
|
||||
from the Help menu.
|
||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||
|
||||
@@ -156,3 +316,7 @@ commonly-worked DXCC entities.
|
||||
`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).*
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||
// just what happens.
|
||||
//
|
||||
// Design notes:
|
||||
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||
// contacts appended AFTER you add the file come in.
|
||||
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||
// the next poll instead of importing a truncated QSO.
|
||||
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||
// auto-log) + automatic external-service upload.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const keyADIFMonitor = "adifmon.config"
|
||||
|
||||
// ADIFWatchFile is one monitored ADIF file.
|
||||
type ADIFWatchFile struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||
// initialised": the first scan sets it to the file's current size so existing
|
||||
// history is skipped.
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||
type ADIFMonitorConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Files []ADIFWatchFile `json:"files"`
|
||||
}
|
||||
|
||||
const eorTag = "<eor>"
|
||||
|
||||
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||
var cfg ADIFMonitorConfig
|
||||
if a.settings == nil {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||
b, _ := json.Marshal(cfg)
|
||||
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||
}
|
||||
|
||||
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
return a.loadADIFMonitorLocked()
|
||||
}
|
||||
|
||||
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||
// was added import); a file already present keeps its current read position.
|
||||
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
old := a.loadADIFMonitorLocked()
|
||||
oldOff := make(map[string]int64, len(old.Files))
|
||||
for _, f := range old.Files {
|
||||
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||
}
|
||||
for i := range cfg.Files {
|
||||
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||
} else {
|
||||
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose an ADIF file to monitor",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||
func (a *App) adifMonitorLoop() {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
if a.ctx == nil || a.qso == nil {
|
||||
continue
|
||||
}
|
||||
a.scanADIFMonitors()
|
||||
}
|
||||
}
|
||||
|
||||
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||
// persisting advanced offsets.
|
||||
func (a *App) scanADIFMonitors() {
|
||||
a.adifMonMu.Lock()
|
||||
cfg := a.loadADIFMonitorLocked()
|
||||
a.adifMonMu.Unlock()
|
||||
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// path → new offset, for the files we advanced this pass.
|
||||
advanced := map[string]int64{}
|
||||
for i := range cfg.Files {
|
||||
f := &cfg.Files[i]
|
||||
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||
continue
|
||||
}
|
||||
fi, err := os.Stat(f.Path)
|
||||
if err != nil {
|
||||
continue // not present (yet) — try again next tick
|
||||
}
|
||||
size := fi.Size()
|
||||
if f.Offset < 0 {
|
||||
// First sight of this file → skip whatever history it already holds.
|
||||
advanced[f.Path] = size
|
||||
continue
|
||||
}
|
||||
if size < f.Offset {
|
||||
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||
}
|
||||
if size == f.Offset {
|
||||
continue // nothing new
|
||||
}
|
||||
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||
if newOff != f.Offset {
|
||||
advanced[f.Path] = newOff
|
||||
}
|
||||
if n > 0 {
|
||||
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(advanced) == 0 {
|
||||
return
|
||||
}
|
||||
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||
// file list: re-read the stored config and only patch offsets for paths that
|
||||
// still exist there.
|
||||
a.adifMonMu.Lock()
|
||||
cur := a.loadADIFMonitorLocked()
|
||||
for i := range cur.Files {
|
||||
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||
cur.Files[i].Offset = off
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cur)
|
||||
a.adifMonMu.Unlock()
|
||||
}
|
||||
|
||||
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return from, 0
|
||||
}
|
||||
defer fh.Close()
|
||||
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||
return from, 0
|
||||
}
|
||||
buf := make([]byte, to-from)
|
||||
nRead, err := io.ReadFull(fh, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return from, 0
|
||||
}
|
||||
buf = buf[:nRead]
|
||||
|
||||
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||
// waits for the next poll.
|
||||
lower := bytes.ToLower(buf)
|
||||
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||
if last < 0 {
|
||||
return from, 0 // no complete record yet
|
||||
}
|
||||
end := last + len(eorTag)
|
||||
chunk := buf[:end]
|
||||
|
||||
count := 0
|
||||
for _, rec := range splitADIFRecords(chunk) {
|
||||
if strings.TrimSpace(rec) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||
count++
|
||||
}
|
||||
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||
}
|
||||
return from + int64(end), count
|
||||
}
|
||||
|
||||
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||
func splitADIFRecords(b []byte) []string {
|
||||
lower := bytes.ToLower(b)
|
||||
var out []string
|
||||
start := 0
|
||||
for {
|
||||
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
end := start + rel + len(eorTag)
|
||||
out = append(out, string(b[start:end]))
|
||||
start = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+20
-13
@@ -122,12 +122,20 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QSLListTemplates returns all saved templates (defaults first).
|
||||
// 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")
|
||||
}
|
||||
recs, err := a.qslTemplates.List(a.ctx)
|
||||
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
|
||||
}
|
||||
@@ -429,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||
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)
|
||||
// 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.
|
||||
//
|
||||
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("QSL card 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)
|
||||
|
||||
@@ -24,6 +24,8 @@ var sensitiveSettingKeys = map[string]bool{
|
||||
keyExtClublogPassword: true,
|
||||
keyExtLoTWKeyPassword: true,
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
}
|
||||
|
||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||
|
||||
@@ -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,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
//go:embed changelog.json
|
||||
var changelogJSON []byte
|
||||
|
||||
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||
// shown once on the first launch after an update (the "What's new" dialog),
|
||||
// derived from the release's commit history.
|
||||
type ChangelogEntry struct {
|
||||
Version string `json:"version"`
|
||||
Date string `json:"date"`
|
||||
EN []string `json:"en"`
|
||||
FR []string `json:"fr"`
|
||||
}
|
||||
|
||||
const keyChangelogSeen = "changelog.last_seen_version"
|
||||
|
||||
func loadChangelog() []ChangelogEntry {
|
||||
var out []ChangelogEntry
|
||||
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||
applog.Printf("changelog: parse failed: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetChangelog returns every changelog entry up to the running version (newest
|
||||
// first), without touching the "seen" marker — for a "What's new" button that
|
||||
// reopens the notes on demand.
|
||||
func (a *App) GetChangelog() []ChangelogEntry {
|
||||
out := []ChangelogEntry{}
|
||||
for _, e := range loadChangelog() {
|
||||
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||
// notes for every version newer than the last one they ran, up to the current
|
||||
// build — and records the current version as seen so it pops exactly once per
|
||||
// update. Empty when there's nothing new.
|
||||
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||
if a.settings == nil {
|
||||
return nil
|
||||
}
|
||||
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||
cur := appVersion
|
||||
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||
|
||||
out := []ChangelogEntry{}
|
||||
for _, e := range loadChangelog() {
|
||||
if strings.TrimSpace(e.Version) == "" {
|
||||
continue
|
||||
}
|
||||
if versionLess(cur, e.Version) {
|
||||
continue // don't preview notes for a version newer than we run
|
||||
}
|
||||
if lastSeen == "" {
|
||||
// First run of the feature (fresh install, or upgrade from a build that
|
||||
// predates it): show only the CURRENT version's notes, once.
|
||||
if e.Version == cur {
|
||||
out = append(out, e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if versionLess(lastSeen, e.Version) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
[
|
||||
{
|
||||
"version": "0.21.0",
|
||||
"date": "2026-07-24",
|
||||
"en": [
|
||||
"⚠️ IMPORTANT — ARCHITECTURE CHANGE. OpsLog now keeps your settings/profiles and your QSO logbook in SEPARATE database files (before, everything was in one file). On the first launch after this update, your existing database is split AUTOMATICALLY and non-destructively: your contacts are copied into a new logbook file (logbook.db) while the originals stay untouched in the settings database as a backup — nothing is deleted, no QSO is lost. Existing installs keep their opslog.db as the settings database; fresh installs name it settings.db. As a precaution, back up your OpsLog data folder before updating. Afterwards, Settings → Database shows the settings database and this profile's logbook as two separate sections.",
|
||||
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it).",
|
||||
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (‘Interlock is preventing transmission’). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the ‘follow the rig’ loop re-sent a tune command on almost every poll, and each command re-armed the ‘block TX while the antenna moves’ window — so the interlock never released. The follow loop now keys its deadband off the rig’s frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an ‘interlock set reason=’ command that SmartSDR rejects (it’s read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR ‘Tunable range’ setting (Settings → Hardware → Antenna, default 13–54 MHz = 20 m–6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m–6 m SteppIR no longer tries to move it or touches the interlock.",
|
||||
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
|
||||
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
|
||||
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
|
||||
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
|
||||
"NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.",
|
||||
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
|
||||
"Finished translating the Settings dialog: several panels that had stayed in English are now French too — the CW Keyer panel (and its long serial-engine paragraph removed for clarity), Password encryption, the ClubLog exceptions / Most Wanted blocks in General, and the External services tabs (HRDLOG, eQSL, LoTW, POTA).",
|
||||
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
|
||||
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
|
||||
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
||||
],
|
||||
"fr": [
|
||||
"⚠️ IMPORTANT — CHANGEMENT D'ARCHITECTURE. OpsLog stocke désormais tes réglages/profils et ton journal de QSO dans des fichiers de base de données SÉPARÉS (avant, tout était dans un seul fichier). Au premier lancement après cette mise à jour, ta base existante est scindée AUTOMATIQUEMENT et sans destruction : tes contacts sont copiés dans un nouveau fichier journal (logbook.db) tandis que les originaux restent intacts dans la base de réglages en sauvegarde — rien n'est supprimé, aucun QSO n'est perdu. Les installs existantes gardent leur opslog.db comme base de réglages ; les nouvelles installs la nomment settings.db. Par précaution, sauvegarde ton dossier de données OpsLog avant de mettre à jour. Ensuite, Réglages → Base de données affiche la base de réglages et le journal de ce profil en deux sections distinctes.",
|
||||
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent).",
|
||||
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
|
||||
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
|
||||
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
|
||||
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
|
||||
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
|
||||
"NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.",
|
||||
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
|
||||
"Fin de la traduction de la fenêtre Réglages : plusieurs panneaux restés en anglais sont maintenant en français aussi — le Manipulateur CW (et son long paragraphe moteur série retiré pour l'alléger), le Chiffrement des mots de passe, les blocs Exceptions ClubLog / Most Wanted dans Général, et les onglets Services externes (HRDLOG, eQSL, LoTW, POTA).",
|
||||
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
|
||||
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
|
||||
"Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.11",
|
||||
"date": "2026-07-23",
|
||||
"en": [
|
||||
"Fixed clicking a spot / band-map entry not moving the radio on Yaesu (and other non-Icom) rigs controlled through OmniRig — e.g. the FT-891. OpsLog tuned via OmniRig's SetSimplexMode, which on these rigs returns success but silently doesn't move the VFO (mode changes worked, so it looked like a puzzle). It now also writes the VFO frequency property directly, which does move them. Icom rigs are unchanged (there SetSimplexMode is the reliable path).",
|
||||
"New CW keyer engine: 'Serial port (DTR=CW / RTS=PTT)'. OpsLog can now key CW by toggling a serial control line itself — the hardware-keying method N1MM/WSJT use with a Yaesu SCU-17 and generic keying interfaces — without a K1EL WinKeyer. Settings → CW Keyer → Keyer engine: pick 'Serial port', choose the keying COM port and whether CW is on DTR (PTT on RTS) or swapped. Speed, Farnsworth, lead-in/tail and PTT keying all apply; macros, auto-CQ and <LOGQSO> work exactly as with the WinKeyer. An 'Invert keying (active-LOW)' option is there for interfaces that key backwards / transmit at rest.",
|
||||
"Fixed the spectrum scope never displaying on the IC-7300: its CI-V waveform frames actually carry the same leading main-scope selector byte as the dual-scope IC-7610/9700, but OpsLog assumed the 7300 omitted it — so it read the frame sequence number from the wrong byte (always 0), discarded every frame, and drew nothing. The frame layout is now detected from the data itself, so the 7300 (and other single-scope Icoms) render correctly, in both center and fixed modes. The IC-7610/9700 are unaffected.",
|
||||
"IC-7300 scope: the center/fixed mode and edge-frequency commands now include the selector byte the rig requires, so switching the scope between center-on-VFO and fixed span from OpsLog is accepted instead of being rejected.",
|
||||
"Closing OpsLog no longer switches off the scope display on the radio itself: turning the scope off used to send both 'stop CI-V data' and 'display off', so quitting blanked a local IC-7300's own scope screen. It now only stops the CI-V data stream on disable and never touches the radio's display (the display-on command is sent solely when enabling)."
|
||||
],
|
||||
"fr": [
|
||||
"Correction : cliquer un spot / une entrée de band-map ne changeait pas la fréquence sur les Yaesu (et autres radios non-Icom) pilotées via OmniRig — p. ex. le FT-891. OpsLog accordait via SetSimplexMode d'OmniRig, qui sur ces radios renvoie « OK » mais ne bouge pas le VFO (le changement de mode marchait, d'où l'énigme). Il écrit maintenant aussi directement la propriété fréquence du VFO, ce qui les fait bien QSY. Les Icom sont inchangés (là, SetSimplexMode reste la bonne méthode).",
|
||||
"Nouveau moteur de keyer CW : « Port série (DTR=CW / RTS=PTT) ». OpsLog peut désormais manipuler la CW en basculant lui-même une ligne de contrôle série — la méthode de keying matériel qu'utilisent N1MM/WSJT avec un Yaesu SCU-17 et les interfaces génériques — sans WinKeyer K1EL. Réglages → Keyer CW → Moteur : choisis « Port série », le port COM de keying et si la CW est sur DTR (PTT sur RTS) ou l'inverse. Vitesse, Farnsworth, lead-in/tail et PTT s'appliquent ; les macros, l'auto-CQ et <LOGQSO> fonctionnent exactement comme avec le WinKeyer. Une option « Inverser le keying (actif-BAS) » est prévue pour les interfaces qui manipulent à l'envers / émettent au repos.",
|
||||
"Correction du scope spectral qui ne s'affichait jamais sur l'IC-7300 : ses trames de forme d'onde CI-V portent en réalité le même octet sélecteur de scope en tête que les IC-7610/9700 (dual-scope), mais OpsLog supposait que le 7300 ne l'envoyait pas — il lisait donc le numéro de séquence de la trame sur le mauvais octet (toujours 0), jetait toutes les trames et ne dessinait rien. La disposition des trames est maintenant détectée à partir des données elles-mêmes, donc le 7300 (et les autres Icom mono-scope) s'affichent correctement, en mode centre comme en fixe. Les IC-7610/9700 ne sont pas affectés.",
|
||||
"Scope IC-7300 : les commandes de mode centre/fixe et de fréquences de bords incluent maintenant l'octet sélecteur requis par la radio, donc basculer le scope entre centre-sur-VFO et span fixe depuis OpsLog est accepté au lieu d'être rejeté.",
|
||||
"Fermer OpsLog n'éteint plus le scope sur la radio elle-même : couper le scope envoyait à la fois « stop données CI-V » et « affichage OFF », donc quitter éteignait l'écran scope d'un IC-7300 local. Désormais seule la diffusion des données CI-V est coupée à la désactivation, l'affichage de la radio n'est jamais touché (la commande d'allumage n'est envoyée qu'à l'activation)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.10",
|
||||
"date": "2026-07-23",
|
||||
"en": [
|
||||
"Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.",
|
||||
"Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel is now the exact same card as the FlexRadio panel's (OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD/drain-current/temperature meters), and if you run several amplifiers they all appear — one card each, no dropdown.",
|
||||
"Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.",
|
||||
"Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.",
|
||||
"Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.",
|
||||
"Digital Voice Keyer: an Auto CQ (like the CW keyer's auto-call) repeats a CQ-labelled voice message on a timer until you stop it, play another slot or press ESC. The DVK now also refuses to transmit on non-phone modes (CW/FT8/RTTY…) — the buttons grey out and it won't key the rig with speech on a data mode.",
|
||||
"The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.",
|
||||
"FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20).",
|
||||
"New Help → 'Send log to F4BPO' (shown only when you have an SMTP server configured in Settings → E-mail): e-mails OpsLog's diagnostic logs (current session, previous session and any crash log) straight to the developer, so a problem you hit in the field can be looked at without hunting for the log files by hand.",
|
||||
"Bulk edit can now set the frequency: pick 'Frequency (MHz)', enter e.g. 7.155, and every selected QSO gets that frequency with its band recomputed to match. Handy for fixing a run that was logged on a stale/default frequency (e.g. 7.000) after CAT control dropped. Out-of-band values are rejected so a typo can't corrupt the batch.",
|
||||
"Fixed the Cluster spot list appearing to 'freeze' at the UTC midnight rollover: the Time column was sorted as plain text, so a spot at 0001Z sorted below 2359Z and new spots after 0000Z dropped to the bottom of the list instead of the top — it looked like the cluster had stopped. The column now sorts by actual arrival time, which crosses midnight correctly.",
|
||||
"Fixed the spectrum scope being blank on the IC-7300 (and other single-scope Icoms) when the scope was in center / VFO-following mode: the multi-frame waveform header read the second frequency field as an absolute high edge, but in center mode it is a span, so the display got an invalid range and drew nothing. It now interprets center+span vs low+high edges the same way the IC-7610 path already did. FIXED mode was unaffected."
|
||||
],
|
||||
"fr": [
|
||||
"Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.",
|
||||
"Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur est désormais exactement la même carte que celle du panneau FlexRadio (OPERATE/STANDBY, ON/OFF, niveau de puissance, barre de puissance de sortie et mesures en direct puissance directe/courant de drain/température), et si tu utilises plusieurs amplis ils apparaissent tous — une carte chacun, sans liste déroulante.",
|
||||
"Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.",
|
||||
"Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.",
|
||||
"Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.",
|
||||
"Manipulateur vocal (DVK) : un Auto CQ (comme l'auto-call du keyer CW) répète un message vocal libellé CQ à intervalle régulier jusqu'à l'arrêt, la lecture d'un autre slot ou ÉCHAP. Le DVK refuse aussi désormais d'émettre hors phonie (CW/FT8/RTTY…) — les boutons sont grisés et il ne manipule plus la radio avec de la voix sur un mode data.",
|
||||
"Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.",
|
||||
"Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20).",
|
||||
"Nouveau Aide → « Envoyer le journal à F4BPO » (affiché seulement si un serveur SMTP est configuré dans Réglages → E-mail) : envoie par e-mail les journaux de diagnostic d'OpsLog (session en cours, session précédente et éventuel journal de crash) directement au développeur, pour qu'un problème rencontré sur le terrain puisse être examiné sans avoir à chercher les fichiers de journal à la main.",
|
||||
"L'édition groupée permet maintenant de définir la fréquence : choisis « Fréquence (MHz) », saisis p. ex. 7.155, et chaque QSO sélectionné reçoit cette fréquence avec sa bande recalculée en conséquence. Pratique pour corriger une série loguée sur une fréquence par défaut/figée (p. ex. 7.000) après une perte du CAT. Les valeurs hors bande sont refusées pour qu'une faute de frappe ne corrompe pas le lot.",
|
||||
"Correction de la liste des spots Cluster qui semblait « se figer » au passage de minuit UTC : la colonne Heure était triée comme du texte, donc un spot à 0001Z se retrouvait sous 2359Z et les nouveaux spots après 0000Z tombaient en bas de la liste au lieu du haut — on aurait dit que le cluster s'était arrêté. La colonne trie désormais selon l'heure d'arrivée réelle, qui franchit minuit correctement.",
|
||||
"Correction du scope spectral vide sur l'IC-7300 (et autres Icom mono-scope) quand le scope était en mode centre / suivi du VFO : l'en-tête de forme d'onde multi-trames lisait le second champ de fréquence comme un bord haut absolu, alors qu'en mode centre c'est un span — l'affichage recevait donc une plage invalide et ne dessinait rien. Il interprète maintenant centre+span vs bords bas+haut comme le faisait déjà le chemin de l'IC-7610. Le mode FIXED n'était pas affecté."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.9",
|
||||
"date": "2026-07-22",
|
||||
"en": [
|
||||
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
|
||||
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
|
||||
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
|
||||
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
|
||||
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
|
||||
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
|
||||
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
|
||||
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
|
||||
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
|
||||
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot.",
|
||||
"Multiple amplifiers: Settings → Amplifier is now a LIST — configure several amps (e.g. two SPEs run in parallel), each with its own name and connection. The amp cards in the Flex panel and Station Control get a dropdown to pick which amp they show, and the bottom status bar shows one clickable chip per amp. Existing single-amp setups migrate automatically.",
|
||||
"FlexRadio panel: the SmartSDR v4 DSP filters are now controllable — NRL and ANFL (legacy LMS), NRS (spectral subtraction), NRF (noise reduction with filter) with their level sliders, plus RNN (AI noise reduction) and ANFT (FFT auto-notch) toggles. The section appears automatically on radios that support them (8000/Aurora series). Layout rebalanced: RIT/XIT moved to the Transmit column and the antenna selection now sits at the top of the Receive column, alongside a new DAX button (SmartSDR's transmit-bar DAX toggle — TX audio from DAX, for WSJT-X & co)."
|
||||
],
|
||||
"fr": [
|
||||
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
|
||||
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
|
||||
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
|
||||
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
|
||||
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
|
||||
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
|
||||
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
|
||||
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
|
||||
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
|
||||
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct.",
|
||||
"Plusieurs amplificateurs : Réglages → Amplificateur est maintenant une LISTE — configurez plusieurs amplis (p. ex. deux SPE en parallèle), chacun avec son nom et sa connexion. Les cartes ampli du panneau Flex et de Station Control ont une liste déroulante pour choisir lequel afficher, et la barre du bas montre une pastille cliquable par ampli. Les configurations mono-ampli existantes migrent automatiquement.",
|
||||
"Panneau FlexRadio : les filtres DSP SmartSDR v4 sont maintenant pilotables — NRL et ANFL (LMS legacy), NRS (soustraction spectrale), NRF (réduction de bruit avec filtre) avec leurs curseurs de niveau, plus les interrupteurs RNN (réduction de bruit par IA) et ANFT (notch automatique FFT). La section apparaît automatiquement sur les radios qui les supportent (séries 8000/Aurora). Mise en page rééquilibrée : RIT/XIT passent dans la colonne Transmit et la sélection d'antennes s'affiche en haut de la colonne Receive, avec un nouveau bouton DAX (le toggle DAX du bandeau transmit de SmartSDR — audio d'émission via DAX, pour WSJT-X & co)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.8",
|
||||
"date": "2026-07-21",
|
||||
"en": [
|
||||
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
|
||||
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
|
||||
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
|
||||
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
|
||||
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
|
||||
],
|
||||
"fr": [
|
||||
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
|
||||
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
|
||||
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
|
||||
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
|
||||
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.7",
|
||||
"date": "2026-07-21",
|
||||
"en": [
|
||||
"New: ACOM amplifier support (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, live telemetry (power, SWR, PA temperature, band, fan) in Settings and the radio panel, over USB serial or an RS232-to-Ethernet bridge. Power-ON works over serial when the cable wires the DTR/RTS pins. The Amplifier settings now pick Brand then Model.",
|
||||
"Multi-operator live sync fixed: the shared-MySQL connection pool was too small, so after a while the grid, chat and 'stations on air' silently stopped updating. The pool is now sized for real multi-op use (with 5 operators it stays well within a max_connections=300 server).",
|
||||
"Live operator status is now always published on a shared MySQL logbook — the Settings toggle is gone. You still go off-air automatically 5 minutes after your last QSO, and the 'stations on air' panel refreshes noticeably faster (a redundant table check on every read was removed).",
|
||||
"The By-Continent statistics donut now totals the same as your QSO count — QSOs whose continent couldn't be resolved are shown as an 'Unknown' slice instead of being dropped (the Continents count still lists only real continents)."
|
||||
],
|
||||
"fr": [
|
||||
"Nouveau : prise en charge des amplificateurs ACOM (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, télémétrie en direct (puissance, ROS, température PA, bande, ventilateur) dans les Réglages et le panneau radio, en USB série ou via un pont RS232-vers-Ethernet. La mise en marche fonctionne en série si le câble relie les broches DTR/RTS. Les réglages Amplificateur se font maintenant par Marque puis Modèle.",
|
||||
"Synchronisation multi-opérateurs corrigée : le pool de connexions MySQL partagé était trop petit, et au bout d'un moment la grille, le chat et « stations on air » cessaient silencieusement de se mettre à jour. Le pool est maintenant dimensionné pour un vrai multi-op (à 5 opérateurs, on reste largement sous un serveur à max_connections=300).",
|
||||
"Le statut opérateur en direct est maintenant toujours publié sur un logbook MySQL partagé — l'option des Réglages a disparu. Vous passez toujours hors antenne automatiquement 5 minutes après votre dernier QSO, et le panneau « stations on air » se rafraîchit nettement plus vite (une vérification de table redondante à chaque lecture a été supprimée).",
|
||||
"La donut « Par continent » des statistiques totalise maintenant le même nombre que le total de QSO — les QSO dont le continent n'a pas pu être résolu apparaissent dans un segment « Unknown » au lieu d'être ignorés (le compteur Continents ne liste toujours que les vrais continents)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.6",
|
||||
"date": "2026-07-21",
|
||||
"en": [
|
||||
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
|
||||
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
|
||||
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
|
||||
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
|
||||
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
|
||||
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
|
||||
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
|
||||
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
|
||||
"Cluster self-spot pop-ups now show the band.",
|
||||
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
|
||||
"Update check now also runs when you open Help - About, and every 5 minutes."
|
||||
],
|
||||
"fr": [
|
||||
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
|
||||
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
|
||||
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
|
||||
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
|
||||
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
|
||||
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
|
||||
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
|
||||
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
|
||||
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
|
||||
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
|
||||
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.5",
|
||||
"date": "2026-07-20",
|
||||
"en": [
|
||||
"New FlexRadio CWX CW keyer: key CW straight over the SmartSDR connection — no WinKeyer or SmartCAT needed. Type-ahead and mid-send backspace supported.",
|
||||
"ESC now stops CW on whichever keyer is active (WinKeyer / Icom / Flex), and typing a callsign aborts a running macro.",
|
||||
"New Amplifier settings section (was 'Power Genius'): control SPE Expert amps (1.3K / 1.5K / 2K) over USB serial or an RS232-to-Ethernet bridge — OPERATE/STANDBY and live status. PowerGenius XL still supported.",
|
||||
"Denkovi USB relay boards supported (4/8 relays, FT245), plus generic CH340/LCUS USB-serial relay boards.",
|
||||
"Rename your database from Settings without losing any configuration.",
|
||||
"Awards: DDFM now finds the French department from the address postal code; refs are stored on each QSO for faster columns; 'Publish to catalog' shares an edited award with your team on the next release; award columns are remembered per profile.",
|
||||
"Statistics: per-band mode split (CW / phone / data), and an activity chart with a Day / Week / Month / Year selector (rolling, real dates).",
|
||||
"'Stations on air' widget updates within seconds and fits more stations.",
|
||||
"The audio CW decoder was removed.",
|
||||
"Fixes: Antenna Genius buttons ignored clicks while a macro was sending; File → Exit; and more.",
|
||||
"This 'What's new' summary now appears on the first launch after each update."
|
||||
],
|
||||
"fr": [
|
||||
"Nouveau keyer CW FlexRadio CWX : manipulation CW directement via la connexion SmartSDR — plus besoin de WinKeyer ni de SmartCAT. Type-ahead et retour arrière en cours d'envoi supportés.",
|
||||
"ESC arrête maintenant le CW sur le moteur actif (WinKeyer / Icom / Flex), et taper un indicatif interrompt une macro en cours.",
|
||||
"Nouvelle section Amplificateur (ex « Power Genius ») : pilotage des SPE Expert (1.3K / 1.5K / 2K) en USB série ou via un pont RS232→Ethernet — OPERATE/STANDBY et état en direct. PowerGenius XL toujours supporté.",
|
||||
"Cartes relais USB Denkovi (4/8 relais, FT245), plus cartes USB-série génériques CH340/LCUS.",
|
||||
"Renommer sa base depuis les réglages sans perdre la configuration.",
|
||||
"Awards : DDFM trouve le département français depuis le code postal de l'adresse ; les réfs sont stockées sur chaque QSO (colonnes plus rapides) ; « Publier au catalogue » partage un award édité avec l'équipe à la prochaine mise à jour ; les colonnes d'award sont mémorisées par profil.",
|
||||
"Statistiques : répartition des modes par bande (CW / phone / data), et un graphe d'activité avec sélecteur Jour / Semaine / Mois / Année (glissant, dates réelles).",
|
||||
"Le widget « Stations on air » se met à jour en quelques secondes et affiche plus de stations.",
|
||||
"Le décodeur CW audio a été retiré.",
|
||||
"Corrections : les boutons Antenna Genius ignoraient les clics pendant l'envoi d'une macro ; Fichier → Quitter ; et d'autres.",
|
||||
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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,100 @@
|
||||
package main
|
||||
|
||||
// One-shot generator: reads the FIPS county CSV and emits
|
||||
// internal/awardref/uscounties_gen.go. Not part of the build.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/award"
|
||||
)
|
||||
|
||||
// dxccForState maps the two US "states" that are separate DXCC entities.
|
||||
func dxccForState(st string) int {
|
||||
switch st {
|
||||
case "AK":
|
||||
return 6
|
||||
case "HI":
|
||||
return 110
|
||||
default:
|
||||
return 291
|
||||
}
|
||||
}
|
||||
|
||||
// territories we exclude entirely — plus DC, which USA-CA does not count as a
|
||||
// county.
|
||||
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
|
||||
|
||||
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
|
||||
// count as counties: the independent cities of Virginia and Carson City (NV).
|
||||
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
|
||||
// one of these counts toward a bordering county under the award rules.
|
||||
func excludedUSACA(state, name string) bool {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
switch state {
|
||||
case "VA":
|
||||
return strings.HasSuffix(n, " city")
|
||||
case "NV":
|
||||
return n == "carson city"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
f, err := os.Open(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
type row struct{ code, name string; dxcc int }
|
||||
var rows []row
|
||||
seen := map[string]bool{}
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Scan() // header
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
parts := strings.SplitN(line, ",", 3)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[1])
|
||||
st := strings.TrimSpace(parts[2])
|
||||
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
|
||||
continue
|
||||
}
|
||||
if excludedUSACA(st, name) {
|
||||
continue
|
||||
}
|
||||
// State header rows have an all-caps state NAME and state code "NA"
|
||||
// (already skipped). County rows have a 2-letter state code.
|
||||
code := award.USCountyKey(st, name)
|
||||
if code == "" || seen[code] {
|
||||
continue
|
||||
}
|
||||
seen[code] = true
|
||||
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
|
||||
b.WriteString("package awardref\n\n")
|
||||
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
|
||||
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
|
||||
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
|
||||
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
|
||||
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
|
||||
}
|
||||
@@ -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?")
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
$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';
|
||||
@@ -27,7 +28,7 @@ $STALE_SECONDS = 120; // an operator is "active" if seen within this wi
|
||||
// instead of a fatal "table doesn't exist".
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
|
||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT);
|
||||
if ($mysqli->connect_errno) {
|
||||
http_response_code(500);
|
||||
exit('DB error');
|
||||
|
||||
+2286
-311
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -21,6 +22,7 @@ interface Props {
|
||||
// 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);
|
||||
|
||||
@@ -68,9 +70,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it,
|
||||
or type a custom/vendor tag (e.g. <code className="bg-muted px-1 rounded font-mono">APP_*</code>).
|
||||
Stored losslessly and exported in the <strong>full</strong> ADIF mode.
|
||||
{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 */}
|
||||
@@ -79,21 +79,21 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
<Combobox
|
||||
value=""
|
||||
options={addable}
|
||||
placeholder="Add ADIF field…"
|
||||
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)} />
|
||||
Show deprecated
|
||||
{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">
|
||||
No extra ADIF fields. Use the picker above to add one.
|
||||
{t('adx.noExtra')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -105,11 +105,11 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
<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 ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''}
|
||||
{def.category}{def.deprecated ? ' · ' + t('adx.deprecated') : ''}{def.intl ? ' · ' + t('adx.intl') : ''}
|
||||
{!def && ''}
|
||||
</span>
|
||||
)}
|
||||
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</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"
|
||||
@@ -118,7 +118,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
/>
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(k)} title="Remove field"
|
||||
onClick={() => remove(k)} title={t('adx.removeField')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -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,260 @@
|
||||
import { useRef } from 'react';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
||||
// so Station Control (and anywhere else that needs it) shows the SAME card instead
|
||||
// of a stripped-down variant. It handles all three amp families:
|
||||
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
|
||||
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
|
||||
// amp), fan mode from the direct GSCP link (amp.pgxl)
|
||||
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
||||
// can each get their own card.
|
||||
|
||||
const METER_SEGMENTS = 26;
|
||||
|
||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: {
|
||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string;
|
||||
segColor?: (frac: number) => 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 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')}>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
|
||||
function speMaxW(model?: string): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
return 1300;
|
||||
}
|
||||
function powerLevelLabel(pl?: string): string {
|
||||
switch ((pl || '').trim().toUpperCase()) {
|
||||
case 'L': return 'Low';
|
||||
case 'M': return 'Mid';
|
||||
case 'H': return 'High';
|
||||
default: return pl || '';
|
||||
}
|
||||
}
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
|
||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||
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;
|
||||
};
|
||||
|
||||
const isSPE = !!amp.spe;
|
||||
const isACOM = !!amp.acom;
|
||||
|
||||
if (isSPE) {
|
||||
const spe = amp.spe;
|
||||
return (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
spe.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')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{powerLevelLabel(lvl)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||
</span>
|
||||
{spe.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{(spe.warnings || spe.alarms) && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||
)}
|
||||
</div>
|
||||
{spe.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||
lo={0} hi={speMaxW(spe.model)}
|
||||
display={`${Number(spe.output_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isACOM) {
|
||||
const acom = amp.acom;
|
||||
return (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
acom.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')}>
|
||||
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||
</span>
|
||||
{acom.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{acom.err_text && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||
)}
|
||||
</div>
|
||||
{acom.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||
lo={0} hi={Number(acom.max_w) || 800}
|
||||
display={`${Number(acom.fwd_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||
const connected = !!pg.connected || viaFlex;
|
||||
const fault = flex?.amp_fault;
|
||||
return (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
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')}>
|
||||
{operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{(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) => AmpFanMode(amp.id, e.target.value).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" />
|
||||
{fault && fault !== 'NONE' && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
||||
{viaFlex && (() => {
|
||||
const meters = (flex?.meters as any[]) || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (amps.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">
|
||||
{amps.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';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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();
|
||||
}
|
||||
|
||||
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||
// would be a new component *type* on every render, so React would unmount and
|
||||
// remount every port button each time the panel re-renders — harmless when that's
|
||||
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||
// different element instances), so antenna changes silently did nothing.
|
||||
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||
onActivate: (port: number, antenna: number) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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} onActivate={onActivate} t={t} />
|
||||
<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} onActivate={onActivate} t={t} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
|
||||
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';
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
ExportAwardForCatalog,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
// Above this many references the editor stops loading the whole list and
|
||||
@@ -29,12 +32,13 @@ type RefMeta = { code: string; count: number; updated_at: string; can_update: bo
|
||||
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; multi?: boolean; dynamic?: boolean; add_prefixes?: 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;
|
||||
total: number; builtin?: boolean; version?: number;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
@@ -102,6 +106,7 @@ function Field2({ label, children }: { label: string; children: React.ReactNode
|
||||
// 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;
|
||||
@@ -124,7 +129,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
|
||||
<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) => (
|
||||
@@ -141,6 +146,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
||||
}
|
||||
|
||||
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>>({});
|
||||
@@ -150,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||
// the selected award's current version whenever the selection changes.
|
||||
const [catVer, setCatVer] = useState('1');
|
||||
|
||||
// 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
|
||||
@@ -173,7 +182,91 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
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;
|
||||
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||
|
||||
// ── 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[]) ?? []);
|
||||
@@ -208,20 +301,55 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
setErr('');
|
||||
try {
|
||||
const p = await ExportAwards();
|
||||
if (p) setErr(`Awards exported to:\n${p}`);
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import an award bundle: definitions are upserted by code, reference lists
|
||||
// replaced. Reloads the editor afterwards.
|
||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||
async function exportForCatalog() {
|
||||
setErr('');
|
||||
if (!cur) return;
|
||||
const v = Math.trunc(Number(catVer));
|
||||
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||
try {
|
||||
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||
if (p) setErr(t('awed.catalogExportedTo', { 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 r: any = await ImportAwards();
|
||||
if (!r || (!r.awards && !r.references)) return; // cancelled
|
||||
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();
|
||||
setErr(`Imported ${r.awards} award(s) and ${r.references} reference(s).`);
|
||||
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) {
|
||||
@@ -242,9 +370,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
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">
|
||||
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>Award management</DialogTitle>
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
@@ -253,37 +381,91 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<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="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<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 }) => (
|
||||
{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-emerald-500')} />
|
||||
<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" /> New award
|
||||
<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="Click to dismiss" 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>}
|
||||
{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">Select or create an award.</div>
|
||||
<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">Award info</TabsTrigger>
|
||||
<TabsTrigger value="type">Award type</TabsTrigger>
|
||||
<TabsTrigger value="conf">Confirmation</TabsTrigger>
|
||||
<TabsTrigger value="refs">References</TabsTrigger>
|
||||
<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">
|
||||
@@ -291,29 +473,48 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<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="Award name" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> Valid</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
<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="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label="Award URL"><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label="Reference URL"><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<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="Valid from"><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label="Valid to"><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
<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">DXCC filter</Label>
|
||||
<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">Valid bands (empty = all)</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">Emission (empty = all)</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">Valid modes (empty = all)</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></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="Award type">
|
||||
<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>{
|
||||
@@ -324,18 +525,20 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> Dynamic references (not predefined — any value counts, like POTA)</label>
|
||||
{/* 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">QSO parameters (used by QSOFIELDS / REFERENCE types)</p>
|
||||
<Field2 label="Search in field">
|
||||
<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="Match by">
|
||||
<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">
|
||||
@@ -344,21 +547,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
))}
|
||||
</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 })} /> Exact match (else search reference inside the field)</label>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /></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="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
<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>
|
||||
|
||||
{/* Additional OR searches: a QSO earns a reference if the
|
||||
primary rule OR any of these match. */}
|
||||
{/* 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">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</p>
|
||||
<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" /> Add OR
|
||||
<Plus className="size-3.5" /> {t('awed.addOr')}
|
||||
</Button>
|
||||
</div>
|
||||
{(cur.or_rules ?? []).map((r, ri) => {
|
||||
@@ -367,7 +572,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
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">OR — search in</span>
|
||||
<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>
|
||||
@@ -379,12 +584,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</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 })} /> exact</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
|
||||
<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="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||
<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>
|
||||
);
|
||||
@@ -397,20 +602,117 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<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">Confirmation (worked → confirmed)</Label>
|
||||
<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">Validation (confirmed → validated)</Label>
|
||||
<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>
|
||||
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> Export award in ADIF credit_granted field</label>
|
||||
{/* "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 ── */}
|
||||
@@ -428,18 +730,97 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title="Export all award definitions + reference lists to a JSON backup">
|
||||
<Download className="size-3.5 mr-1" /> Export…
|
||||
<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="Import an award bundle (definitions + reference lists)">
|
||||
<Upload className="size-3.5 mr-1" /> Import…
|
||||
<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>
|
||||
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||
file to paste over internal/award/catalog/<code>.json, so a release
|
||||
ships your change to the whole team. */}
|
||||
{cur && (
|
||||
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||
<Button variant="outline" onClick={exportForCatalog}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -451,6 +832,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
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);
|
||||
@@ -486,7 +868,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
}, [code, q, large]);
|
||||
|
||||
async function populateBuiltin() {
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(t('awed.populatedMsg', { n })); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -505,7 +887,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function addRef() {
|
||||
const c = prompt('New reference code:')?.trim().toUpperCase();
|
||||
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);
|
||||
@@ -520,7 +902,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function importBulk() {
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(`Imported ${n} references.`); }
|
||||
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)); }
|
||||
}
|
||||
|
||||
@@ -528,46 +910,46 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
|
||||
<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="Apply preset…" /></SelectTrigger>
|
||||
<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)}>Paste / CSV</Button>
|
||||
<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="Replace with the shipped built-in list (DXCC entities, French departments, …)">
|
||||
Populate built-in
|
||||
<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" />} Update online
|
||||
{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" /> Add</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">One reference per line: <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> (comma/semicolon/tab). Replaces the whole list.</p>
|
||||
<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)}>Cancel</Button><Button size="sm" className="h-7" onClick={importBulk}>Import</Button></div>
|
||||
<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="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<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" /> Searching…</div>}
|
||||
{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-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
|
||||
<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">No references.</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')}>
|
||||
@@ -581,37 +963,29 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
{/* Per-reference editor */}
|
||||
<div className="border rounded p-3">
|
||||
{!sel ? (
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">Select a reference, or Add / import a list.</div>
|
||||
<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 })} /> Valid</label>
|
||||
<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="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
</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="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Score</Label>
|
||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Bonus</Label>
|
||||
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Grid</Label>
|
||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 };
|
||||
|
||||
@@ -17,6 +18,7 @@ interface Props {
|
||||
// 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);
|
||||
@@ -48,14 +50,14 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
||||
<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-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
|
||||
<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-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
|
||||
<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={`Search ${label}…`}
|
||||
placeholder={t('awrp.searchLabel', { label })}
|
||||
value={q}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
@@ -63,9 +65,9 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
||||
)}
|
||||
{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" /> Searching…</div>}
|
||||
{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">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
|
||||
<div className="px-3 py-2 text-muted-foreground">{countryOnly && dxcc ? t('awrp.noMatchDxcc') : t('awrp.noMatch')}</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../.
|
||||
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 };
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -52,6 +54,18 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
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]) => {
|
||||
@@ -196,24 +210,24 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
{/* 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]">Group</span>
|
||||
<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]">Sub</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-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
|
||||
<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-emerald-700">{selectedRef.name}</span>
|
||||
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
|
||||
<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">
|
||||
← pick a reference
|
||||
{t('awrs.pickReference')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -224,10 +238,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
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" />Add
|
||||
<Plus className="size-3" />{t('awrs.add')}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
|
||||
{dxcc ? `DXCC #${dxcc}` : t('awrs.enterCallsignFirst')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -236,7 +250,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
{/* 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">No references added yet</p>
|
||||
<p className="text-[11px] text-muted-foreground italic py-0.5">{t('awrs.noRefsAdded')}</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
@@ -261,7 +275,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
{/* 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">References</span>
|
||||
<span className="text-xs font-semibold">{t('awrs.references')}</span>
|
||||
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
||||
{autoMatch && (
|
||||
<button
|
||||
@@ -270,20 +284,20 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
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-emerald-300 bg-emerald-50 text-emerald-800 cursor-default'
|
||||
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100'
|
||||
? '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={`The ${selField.toUpperCase()} field is ${autoMatch.code} — this award counts it automatically`}
|
||||
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
||||
>
|
||||
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||
<span className="text-emerald-700"> from {selField}</span>
|
||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">auto — click to add</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="Search…"
|
||||
placeholder={t('awrs.search')}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
@@ -294,22 +308,22 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
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="Add this reference even though it isn't in the list yet (new / unlisted)"
|
||||
title={t('awrs.addUnlistedTitle')}
|
||||
>
|
||||
+ Add <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||
<span className="text-[10px] text-muted-foreground"> (unlisted)</span>
|
||||
{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" />Searching…
|
||||
<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">
|
||||
Type 2+ chars to search
|
||||
{t('awrs.typeToSearch')}
|
||||
</div>
|
||||
)}
|
||||
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||
@@ -317,16 +331,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
{isDynamic && !dxcc
|
||||
? 'Enter a callsign, or type to search.'
|
||||
: 'No references for this entity.'}
|
||||
? 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">
|
||||
No results.
|
||||
{t('awrs.noResults')}
|
||||
<br />
|
||||
<span className="text-[10px]">Download reference lists in the Awards panel → Import data.</span>
|
||||
<span className="text-[10px]">{t('awrs.downloadLists')}</span>
|
||||
</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 = {
|
||||
@@ -35,9 +36,9 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||
return 'none';
|
||||
}
|
||||
const CELL_STYLE: Record<CellStatus, string> = {
|
||||
validated: 'bg-emerald-500 text-white',
|
||||
confirmed: 'bg-amber-400 text-amber-950',
|
||||
worked: 'bg-stone-400 text-white',
|
||||
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: '' };
|
||||
@@ -52,15 +53,16 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
||||
if (total <= 0) return null;
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
||||
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||
<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 }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
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).
|
||||
@@ -207,23 +209,23 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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">Awards</span>
|
||||
<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="Edit awards">
|
||||
<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="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
||||
title={t('awp.rescanTitle')}>
|
||||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
Rescan
|
||||
{t('awp.rescan')}
|
||||
</Button>
|
||||
</div>
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
||||
<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="Select an award…" /></SelectTrigger>
|
||||
<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">
|
||||
@@ -253,9 +255,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<span className="font-semibold text-sm">{a.code}</span>
|
||||
{r ? (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
<span className="text-emerald-600">{r.confirmed}</span>
|
||||
<span className="text-success">{r.confirmed}</span>
|
||||
/<span className="text-foreground">{r.worked}</span>
|
||||
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</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>
|
||||
@@ -273,7 +275,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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 ? 'Computing…' : 'No data'}
|
||||
{loading ? t('awp.computing') : t('awp.noData')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -283,11 +285,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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">worked</span></span>
|
||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
|
||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
|
||||
<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">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
|
||||
<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>
|
||||
@@ -296,12 +298,12 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{/* 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">By band (confirmed / worked)</div>
|
||||
<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-emerald-600 font-mono">{b.confirmed}</span>
|
||||
<span className="text-success font-mono">{b.confirmed}</span>
|
||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -313,35 +315,35 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
<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', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
||||
{([['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} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||
<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-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
||||
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
||||
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" /> Missing refs
|
||||
<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-stone-400" />W</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
|
||||
<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="Grid view" 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="List view" 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="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||
<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>
|
||||
|
||||
@@ -349,15 +351,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{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" /> Computing…</div>
|
||||
<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">Statistic</th>
|
||||
<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">Total</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</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>
|
||||
@@ -368,7 +370,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||
<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>
|
||||
@@ -384,8 +386,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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">Ref</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
|
||||
<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>
|
||||
))}
|
||||
@@ -405,7 +407,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{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={`${r.ref} · ${b} — click to view QSOs`}
|
||||
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
)}
|
||||
@@ -422,11 +424,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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">Ref</th>
|
||||
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||||
<th className="py-1.5 font-medium">Bands</th>
|
||||
<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>
|
||||
@@ -436,14 +438,14 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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">— missing</span>
|
||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
||||
: r.confirmed ? <span className="text-amber-600">confirmed</span>
|
||||
: <span className="text-stone-500">worked</span>}
|
||||
{!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-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
|
||||
<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>
|
||||
@@ -472,6 +474,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
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());
|
||||
@@ -534,7 +537,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
const done = new Set(assignedIds);
|
||||
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||
setSel(new Set());
|
||||
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||||
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
|
||||
} catch (e: any) {
|
||||
setMsg(String(e?.message ?? e));
|
||||
} finally { setBusy(false); }
|
||||
@@ -555,27 +558,26 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
<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-amber-600" />
|
||||
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
||||
<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="Recompute now — contacts you've fixed drop off the list">
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
|
||||
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">
|
||||
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
||||
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||||
{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">{sel.size} selected →</span>
|
||||
<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="Choose a reference to assign…" /></SelectTrigger>
|
||||
<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}>
|
||||
@@ -585,29 +587,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
||||
</Button>
|
||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
||||
{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" /> Scanning…</div>
|
||||
<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">
|
||||
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)
|
||||
{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="Date (UTC)" className="pl-0" />
|
||||
<SortTh k="callsign" label="Callsign" />
|
||||
<SortTh k="band" label="Band" />
|
||||
<SortTh k="mode" label="Mode" />
|
||||
<SortTh k="country" label="Country" />
|
||||
<SortTh k="qth" label="QTH / Note" />
|
||||
<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>
|
||||
@@ -633,8 +635,8 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
|
||||
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
|
||||
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
|
||||
{qsos.length} {t('awp.contactsWithoutRef')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -643,6 +645,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
|
||||
// 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(() => {
|
||||
@@ -666,13 +669,13 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
|
||||
</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" /> Loading…</div>
|
||||
<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">No QSOs.</div>
|
||||
<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">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||
<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) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
|
||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||
@@ -34,6 +35,12 @@ interface Props {
|
||||
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]> = {
|
||||
@@ -54,17 +61,17 @@ 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.
|
||||
@@ -79,13 +86,13 @@ function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
|
||||
// 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): string {
|
||||
function statusLabel(s: string, t: (k: string) => string): string {
|
||||
switch (s) {
|
||||
case 'new': return 'NEW DXCC (entity never worked)';
|
||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
||||
case 'worked': return 'Worked (this band + mode already in log)';
|
||||
default: return 'Entity not resolved';
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,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',
|
||||
@@ -146,10 +153,11 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: 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);
|
||||
|
||||
@@ -165,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
|
||||
@@ -197,7 +210,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
// 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';
|
||||
const ftSpots = inBand.filter(isFlood);
|
||||
// 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
|
||||
@@ -270,48 +284,39 @@ 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, spotStatus, currentFreqHz]);
|
||||
}, [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;
|
||||
|
||||
// Auto-centre on the rig frequency when the map opens or the band changes
|
||||
// (once per band, so it doesn't fight the user's manual scrolling). Waits
|
||||
// for the scroller height to be measured and a valid in-band rig freq.
|
||||
const centeredForRef = useRef<string>('');
|
||||
// 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 || currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
if (centeredForRef.current === band) return;
|
||||
if (!range || containerH <= 0) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
centeredForRef.current = band;
|
||||
// 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]);
|
||||
|
||||
// Re-centre on the rig frequency whenever the zoom level changes — zooming
|
||||
// would otherwise drift away from where you're operating. Skips the initial
|
||||
// mount (the band-centre effect above handles that).
|
||||
const firstZoomRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstZoomRef.current) { firstZoomRef.current = false; return; }
|
||||
const el = scrollerRef.current;
|
||||
if (!el || !range || containerH <= 0) return;
|
||||
const kHz = currentFreqHz && currentFreqHz / 1000 >= lo && currentFreqHz / 1000 <= hi
|
||||
? currentFreqHz / 1000 : (lo + hi) / 2;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomIdx]);
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
@@ -330,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>
|
||||
);
|
||||
}
|
||||
@@ -363,35 +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' ? 'Move band map to the left' : 'Move band map to the right'}>
|
||||
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>
|
||||
)}
|
||||
@@ -496,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}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${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 />
|
||||
@@ -515,14 +520,14 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
</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-rose-400" label="New DXCC" />
|
||||
<LegendDot cls="bg-amber-400" label="New band" />
|
||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
||||
<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
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden — top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
|
||||
{t('bmp.footerHint')}
|
||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,22 +51,27 @@ 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-emerald-700', label: 'Call confirmed' },
|
||||
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
||||
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
||||
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
||||
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
||||
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' },
|
||||
{ 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 {
|
||||
@@ -89,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
const dxcc = wb?.dxcc ?? 0;
|
||||
const dxccName = wb?.dxcc_name ?? '';
|
||||
const dxccCount = wb?.dxcc_count ?? 0;
|
||||
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
|
||||
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||
const hasDxcc = dxcc > 0;
|
||||
const newOne = hasDxcc && dxccCount === 0;
|
||||
@@ -104,14 +110,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
// "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.
|
||||
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||
// count as ONE — then FT4 after FT8 is just "worked".
|
||||
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||
const normMode = (m: string): string => {
|
||||
const u = (m || '').toUpperCase().trim();
|
||||
if (!groupDigital) return u;
|
||||
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||
};
|
||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||
const curMode = (currentMode || '').toUpperCase().trim();
|
||||
const curMode = normMode(currentMode);
|
||||
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);
|
||||
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === 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.
|
||||
@@ -124,30 +137,46 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
const newMode = slotNew && bandWorked && !modeWorked;
|
||||
const newSlot = slotNew && bandWorked && modeWorked;
|
||||
|
||||
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
|
||||
// is on). Hotter colour the more wanted the entity is.
|
||||
const mwBadge = mwRank > 0 ? (
|
||||
<Badge
|
||||
title={`ClubLog Most Wanted #${mwRank}`}
|
||||
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
|
||||
mwRank <= 100 ? 'bg-danger text-danger-foreground'
|
||||
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
|
||||
: 'bg-muted text-muted-foreground')}
|
||||
>
|
||||
MW #{mwRank}
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
||||
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg',
|
||||
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>
|
||||
{mwBadge}
|
||||
</>
|
||||
) : hasDxcc ? (
|
||||
<>
|
||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||
{dxccName || `DXCC #${dxcc}`}
|
||||
</Badge>
|
||||
{mwBadge}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||
@@ -161,10 +190,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</span>
|
||||
{(newBand || newMode || newBandMode || newSlot) && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
@@ -220,8 +249,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||
className={cn(
|
||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
||||
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -240,7 +269,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
className={cn(
|
||||
'inline-block size-3 rounded shrink-0',
|
||||
l.swatch,
|
||||
l.ring && 'ring-2 ring-amber-500 ring-inset',
|
||||
l.ring && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
{l.label}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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' | 'freq';
|
||||
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 (location / activation refs / SIG)
|
||||
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||
{ 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
|
||||
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||
// logged on a stale/default frequency after CAT dropped.
|
||||
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
|
||||
{ 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}
|
||||
inputMode={def.kind === 'freq' ? 'decimal' : undefined}
|
||||
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : 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}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</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,8 +1,9 @@
|
||||
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, FilterX } from 'lucide-react';
|
||||
import {
|
||||
@@ -12,30 +13,13 @@ 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;
|
||||
@@ -67,6 +51,8 @@ export type SpotStatusEntry = {
|
||||
country?: string;
|
||||
continent?: string;
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -117,205 +103,278 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
// 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(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
type Badge = { text: string; fg: string; bg: string; bd: string };
|
||||
|
||||
// tok builds a badge from a semantic status token (success/warning/caution/
|
||||
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
||||
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
||||
function tok(name: string, text: string): Badge {
|
||||
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||
}
|
||||
|
||||
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||
// null → plain text, no pill.
|
||||
function cellChip(value: any, name: string | null): any {
|
||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
||||
// callsign stays the same size as the plain callsigns next to it — just tinted
|
||||
// and rounded, not shrunk.
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
||||
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
||||
}}>{txt}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
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' },
|
||||
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
|
||||
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
|
||||
// below "2359Z", so spots received just after midnight fell to the bottom
|
||||
// and looked like the cluster had stopped. received_at is a full datetime
|
||||
// that keeps ordering correct across 0000Z.
|
||||
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
|
||||
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
|
||||
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
|
||||
return ta - tb;
|
||||
},
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
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,
|
||||
cellClass: 'font-mono',
|
||||
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
||||
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
|
||||
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
|
||||
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
|
||||
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
||||
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||
// with the rest of the row instead of always shouting a colour.
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Status', colId: 'status',
|
||||
headerName: 'Status', width: 96, sortable: true,
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 120, 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.
|
||||
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
||||
// and NEW POTA are orthogonal, so they stack as extra badges.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return 'NEW DXCC';
|
||||
if (s?.status === 'new-band') return 'NEW BAND';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||
return s?.worked_call ? 'WKD CALL' : '';
|
||||
const parts: string[] = [];
|
||||
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||
return parts.join(' ');
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
const s = statusFor(p);
|
||||
const badges: Badge[] = [];
|
||||
const b = statusBadge(t, s);
|
||||
if (b) badges.push(b);
|
||||
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
||||
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
||||
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', 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>
|
||||
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
{badges.map((bd, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
||||
fontWeight: 700, fontSize: 9,
|
||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{bd.text}</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
||||
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||
if (s?.worked_call) return 'Already worked this call';
|
||||
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: 'POTA', colId: 'pota',
|
||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
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 ? `POTA — ${p.data.pota_name}` : undefined),
|
||||
cellStyle: { color: 'var(--success)' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : 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.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: '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' ? 'NEW BAND for this entity' : undefined),
|
||||
// NEW BAND for this entity → small warning pill around the band text.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||
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: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
// NEW SLOT (mode not yet worked on this band) → fill the cell.
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot'
|
||||
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }
|
||||
: undefined),
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined),
|
||||
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||
// that case is signalled by the Status badge alone.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||
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' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
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)
|
||||
]?.country ?? '',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
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)
|
||||
]?.continent ?? '',
|
||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||
cellStyle: { color: 'var(--muted-foreground)', 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' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
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 },
|
||||
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
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: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
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,
|
||||
@@ -327,6 +386,14 @@ 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) {
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
@@ -384,11 +451,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<>
|
||||
<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="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
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' }}>
|
||||
@@ -417,9 +484,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">
|
||||
@@ -429,10 +496,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">
|
||||
@@ -451,8 +518,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';
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
@@ -35,8 +36,11 @@ 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".
|
||||
@@ -72,35 +76,35 @@ export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
// 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: 'Aircraft Scatter' },
|
||||
{ value: 'AUR', label: 'Aurora' },
|
||||
{ value: 'AUE', label: 'Aurora-E' },
|
||||
{ value: 'BS', label: 'Back Scatter' },
|
||||
{ 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: 'Earth-Moon-Earth' },
|
||||
{ value: 'ES', label: 'Sporadic E' },
|
||||
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
||||
{ value: 'F2', label: 'F2 Reflection' },
|
||||
{ value: 'GWAVE', label: 'Ground Wave' },
|
||||
{ value: 'INTERNET', label: 'Internet-assisted' },
|
||||
{ value: 'ION', label: 'Ionoscatter' },
|
||||
{ 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: 'Line of Sight' },
|
||||
{ value: 'MS', label: 'Meteor Scatter' },
|
||||
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
||||
{ value: 'RS', label: 'Rain Scatter' },
|
||||
{ value: 'SAT', label: 'Satellite' },
|
||||
{ value: 'TEP', label: 'Trans-Equatorial' },
|
||||
{ value: 'TR', label: 'Tropospheric Ducting' },
|
||||
{ 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: 'Short Path' },
|
||||
{ value: 'L', label: 'Long Path' },
|
||||
{ value: 'G', label: 'Grayline' },
|
||||
{ value: 'O', label: 'Other' },
|
||||
{ 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 {
|
||||
@@ -120,6 +124,7 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -188,11 +193,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}
|
||||
|
||||
const tabs: { key: TabName; label: string }[] = [
|
||||
{ key: 'stats', label: `Stats (${fk}1)` },
|
||||
{ key: 'info', label: `Info (${fk}2)` },
|
||||
{ key: 'awards', label: `Awards (${fk}3)` },
|
||||
{ key: 'my', label: `My (${fk}4)` },
|
||||
{ key: 'extended', label: `Extended (${fk}5)` },
|
||||
{ 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 (
|
||||
@@ -214,7 +219,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<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() !== ''} />
|
||||
@@ -223,27 +228,27 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{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>
|
||||
<Field label="CQ zone">
|
||||
<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="ITU zone">
|
||||
<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="DXCC #">
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -252,7 +257,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Azimuth LP">
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -261,7 +266,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance SP">
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -270,7 +275,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance LP">
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -279,13 +284,13 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
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>
|
||||
@@ -302,7 +307,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
||||
<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}
|
||||
@@ -315,49 +320,49 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{open === 'my' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="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="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="TX power (W)">
|
||||
<Field label={t('detp.txPower')}>
|
||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<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)} />
|
||||
Satellite mode
|
||||
{t('detp.satelliteMode')}
|
||||
</label>
|
||||
</div>
|
||||
<Field label="Ant. path" span={2}>
|
||||
<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>
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Propagation" span={4}>
|
||||
<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}>{p.label}</SelectItem>)}
|
||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<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>
|
||||
</>
|
||||
@@ -367,16 +372,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
||||
import { Mic, Square, X, Radio, Repeat } 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 };
|
||||
@@ -11,25 +12,32 @@ type Props = {
|
||||
onPlay: (slot: number) => void;
|
||||
onStop: () => void;
|
||||
onClose: () => void;
|
||||
autoCq: boolean;
|
||||
autoCqSecs: number;
|
||||
onToggleAutoCq: (on: boolean) => void;
|
||||
onSetAutoCqSecs: (n: number) => void;
|
||||
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||
};
|
||||
|
||||
// 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 anyAudio = messages.some((m) => m.has_audio);
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||
const { t } = useI18n();
|
||||
const recorded = messages.filter((m) => m.has_audio);
|
||||
const anyAudio = recorded.length > 0;
|
||||
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">Voice keyer</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||||
{status.playing && <span className="text-[10px] text-danger 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" /> Stop
|
||||
<Square className="size-3" /> {t('dvkp.stop')}
|
||||
</Button>
|
||||
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
|
||||
<button className="text-muted-foreground hover:text-foreground" title={t('dvkp.disable')} onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -38,32 +46,57 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
{!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" />
|
||||
No messages recorded yet. Open <strong>Settings → Audio devices & voice keyer</strong> to record F1–F6.
|
||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{messages.map((m) => (
|
||||
// Only the recorded slots are shown. Two columns filling top-to-bottom
|
||||
// (rows sized to the count) — use the width instead of scrolling, so the
|
||||
// panel keeps the same height as the other reserved-area widgets.
|
||||
<div className="grid grid-flow-col gap-1"
|
||||
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
|
||||
{recorded.map((m) => (
|
||||
<button
|
||||
key={m.slot}
|
||||
type="button"
|
||||
disabled={!m.has_audio}
|
||||
disabled={!phoneOk}
|
||||
onClick={() => onPlay(m.slot)}
|
||||
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
|
||||
title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||
m.has_audio
|
||||
phoneOk
|
||||
? '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 ? 'message' : '—')}</span>
|
||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||||
<span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</span>
|
||||
{(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
|
||||
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
|
||||
non-phone mode (the DVK won't transmit there). */}
|
||||
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
|
||||
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
|
||||
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
|
||||
onChange={(e) => onToggleAutoCq(e.target.checked)} />
|
||||
<Repeat className="size-3" /> {t('dvkp.autoCq')}
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
|
||||
<input type="number" min={0} max={120} value={autoCqSecs}
|
||||
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
|
||||
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
|
||||
<span className="text-[10px] text-muted-foreground">s</span>
|
||||
</div>
|
||||
|
||||
{!phoneOk && (
|
||||
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App';
|
||||
import { adif } from '@/../wailsjs/go/models';
|
||||
|
||||
type FieldDef = adif.FieldDef;
|
||||
const PREF_KEY = 'opslog.exportFields';
|
||||
|
||||
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
||||
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
||||
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
||||
// chosen set is returned to the parent (which runs the selected/global export)
|
||||
// and persisted so it's the default next time.
|
||||
export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||
open: boolean;
|
||||
count: number; // how many QSOs the pending export covers
|
||||
onExport: (fields: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [official, setOfficial] = useState<FieldDef[]>([]);
|
||||
const [extras, setExtras] = useState<string[]>([]);
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
|
||||
const defaultSet = (flds: FieldDef[]) =>
|
||||
new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]);
|
||||
if (cancelled) return;
|
||||
const flds = (f ?? []) as FieldDef[];
|
||||
setOfficial(flds);
|
||||
setExtras((e ?? []) as string[]);
|
||||
let saved: string[] | null = null;
|
||||
try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ }
|
||||
setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds));
|
||||
})().catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [open]);
|
||||
|
||||
// Official fields grouped by category (deprecated ones are import-only → hidden).
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, FieldDef[]>();
|
||||
for (const d of official) {
|
||||
if (d.deprecated) continue;
|
||||
const g = d.category || 'Misc';
|
||||
(m.get(g) ?? m.set(g, []).get(g)!).push(d);
|
||||
}
|
||||
return [...m.entries()];
|
||||
}, [official]);
|
||||
|
||||
const toggle = (name: string, on: boolean) =>
|
||||
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||
const setMany = (names: string[], on: boolean) =>
|
||||
setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; });
|
||||
|
||||
const doExport = () => {
|
||||
const fields = [...sel];
|
||||
writeUiPref(PREF_KEY, JSON.stringify(fields));
|
||||
onExport(fields);
|
||||
};
|
||||
|
||||
const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => (
|
||||
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
||||
<span className="flex gap-1.5 shrink-0">
|
||||
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => setMany(tags, true)}>{t('exf.all')}</button>
|
||||
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</button>
|
||||
</span>
|
||||
</div>
|
||||
{tags.map((name) => (
|
||||
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
||||
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => toggle(name, !!c)} />
|
||||
<span className="font-mono break-all">{name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('exf.title')}</DialogTitle>
|
||||
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||
{t('exf.defaults')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
||||
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn />}
|
||||
{groups.map(([g, list]) => (
|
||||
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
|
||||
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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
|
||||
@@ -27,70 +28,83 @@ export interface QueryFilter {
|
||||
// (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: 'Callsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
|
||||
{ value: 'band', label: 'Band', type: 'text' },
|
||||
{ value: 'band_rx', label: 'RX band', type: 'text' },
|
||||
{ value: 'mode', label: 'Mode', type: 'text' },
|
||||
{ value: 'submode', label: 'Submode', type: 'text' },
|
||||
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
|
||||
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
|
||||
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
|
||||
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
|
||||
{ value: 'name', label: 'Name', type: 'text' },
|
||||
{ value: 'qth', label: 'QTH', type: 'text' },
|
||||
{ value: 'address', label: 'Address', type: 'text' },
|
||||
{ value: 'email', label: 'E-mail', type: 'text' },
|
||||
{ value: 'grid', label: 'Grid', type: 'text' },
|
||||
{ value: 'country', label: 'Country', type: 'text' },
|
||||
{ value: 'state', label: 'State', type: 'text' },
|
||||
{ value: 'cnty', label: 'County', type: 'text' },
|
||||
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
|
||||
{ value: 'cont', label: 'Continent', type: 'text' },
|
||||
{ value: 'cqz', label: 'CQ zone', type: 'number' },
|
||||
{ value: 'ituz', label: 'ITU zone', type: 'number' },
|
||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
||||
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
||||
{ value: 'rig', label: 'Rig', type: 'text' },
|
||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
||||
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
||||
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
|
||||
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
|
||||
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
|
||||
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
|
||||
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
||||
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
||||
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
|
||||
{ value: 'sat_name', label: 'Satellite', type: 'text' },
|
||||
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
|
||||
{ value: 'operator', label: 'Operator', type: 'text' },
|
||||
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
||||
{ value: 'my_country', label: 'My country', type: 'text' },
|
||||
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
||||
{ 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: 'equals (=)' },
|
||||
{ value: 'ne', label: 'not equal (≠)' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'startswith', label: 'starts with' },
|
||||
{ value: 'endswith', label: 'ends with' },
|
||||
{ value: 'gt', label: 'greater than (>)' },
|
||||
{ value: 'lt', label: 'less than (<)' },
|
||||
{ value: 'ge', label: 'greater or equal (≥)' },
|
||||
{ value: 'le', label: 'less or equal (≤)' },
|
||||
{ value: 'empty', label: 'is empty' },
|
||||
{ value: 'notempty', label: 'is not empty' },
|
||||
{ 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'];
|
||||
@@ -119,6 +133,13 @@ interface Props {
|
||||
}
|
||||
|
||||
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>>({});
|
||||
@@ -171,25 +192,25 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader className="px-6 py-4">
|
||||
<DialogTitle>QSO filter</DialogTitle>
|
||||
<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">Match</span>
|
||||
<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' ? 'ALL (AND)' : 'ANY (OR)'}
|
||||
{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="Load preset…" /></SelectTrigger>
|
||||
<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}>
|
||||
@@ -207,13 +228,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
{/* 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">No conditions — the list shows all QSOs. Add one below.</div>
|
||||
<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 ? 'WHERE' : match}</span>
|
||||
<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);
|
||||
@@ -221,49 +243,50 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
}}>
|
||||
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}
|
||||
{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}>{o.label}</SelectItem>)}
|
||||
{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 ? 'value' : '—'}
|
||||
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="Remove">
|
||||
<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" /> Add condition
|
||||
<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="Preset name…" value={presetName}
|
||||
<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" /> Save preset
|
||||
<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([]); }}>Clear</Button>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={apply}>Apply & close</Button>
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -13,6 +14,7 @@ type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
// (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('');
|
||||
@@ -66,50 +68,66 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
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">Welcome to OpsLog</h2>
|
||||
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
||||
{t('frm.intro')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
|
||||
<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">Locator <span className="text-red-500">*</span></Label>
|
||||
<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">Operator</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: 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">Owner</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: 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">Name</Label>
|
||||
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: 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">Award reference lists</div>
|
||||
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
||||
<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' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
|
||||
{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-emerald-700' : 'text-red-600')}>{refsMsg}</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-red-600">{err}</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">Callsign and locator are required.</span>}
|
||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
||||
{!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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
|
||||
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: '',
|
||||
@@ -81,10 +99,11 @@ interface WorldProps {
|
||||
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 }: WorldProps) {
|
||||
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);
|
||||
@@ -102,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// One-time map creation.
|
||||
useEffect(() => {
|
||||
if (worldRef.current && !worldMap.current) {
|
||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
||||
// 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);
|
||||
const bm = BASEMAPS[basemap];
|
||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
worldOverlay.current = L.layerGroup().addTo(m);
|
||||
worldMap.current = m;
|
||||
const sv = loadMapView();
|
||||
@@ -114,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||
}
|
||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||
return () => window.clearTimeout(t);
|
||||
// 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
|
||||
@@ -122,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) return;
|
||||
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
||||
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
||||
const bm = BASEMAPS[basemap];
|
||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
}, [basemap]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
@@ -139,18 +161,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
const from = gridToLatLon(fromGrid);
|
||||
const to = gridToLatLon(toGrid);
|
||||
|
||||
if (from && to) {
|
||||
// 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);
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
||||
// line, which makes a smooth arc look angular/bumpy).
|
||||
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (beamAzimuths && beamAzimuths.length) {
|
||||
@@ -169,21 +186,20 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
return out;
|
||||
};
|
||||
for (const az of beamAzimuths) {
|
||||
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
||||
// filled polygon: a polygon breaks badly near the poles on Mercator
|
||||
// (its edges run off toward ±90° and the fill smears across the map),
|
||||
// while each radial LINE stays clean. The overlapping lines read as a
|
||||
// lobe — solid near the antenna, fanning out toward the front. Works
|
||||
// for any azimuth, north/south included.
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
||||
// 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.12, smoothFactor: 0 }).addTo(wo);
|
||||
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 (esp. dark satellite imagery). Same dashArray
|
||||
// as the red line so the casing tracks each dash — otherwise the wide
|
||||
// casing peeks through the gaps and the line looks bumpy.
|
||||
// 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);
|
||||
@@ -204,20 +220,39 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
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]]);
|
||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
}
|
||||
} else if (autoZoom && to) {
|
||||
} else if (to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
} else if (autoZoom && from) {
|
||||
} else if (from) {
|
||||
wm.setView([from.lat, from.lon], 3);
|
||||
}
|
||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||
}
|
||||
// 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, autoZoom]);
|
||||
}, [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);
|
||||
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
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, ChevronUp, ChevronDown } 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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
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: '' });
|
||||
|
||||
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||
type QSOMenuHandlers = {
|
||||
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;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onLogged?: () => void;
|
||||
countries?: string[];
|
||||
bands?: string[];
|
||||
modes?: string[];
|
||||
qsoMenuHandlers?: QSOMenuHandlers;
|
||||
};
|
||||
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: 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);
|
||||
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||
// Programmatic row selection in the on-air grid (auto-advance after logging).
|
||||
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
|
||||
|
||||
// Add/edit-contact dialog.
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
const [contact, setContact] = useState<Station>(emptyStation());
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
const rosterGrid = useRef<any>(null);
|
||||
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||
const onAirApi = useRef<any>(null);
|
||||
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||
const activateRef = useRef<(call: string) => void>(() => {});
|
||||
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||
const wireDropZones = useCallback(() => {
|
||||
const rApi = rosterGrid.current?.api;
|
||||
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||
dropZonesDone.current.roster = true;
|
||||
rApi.addRowDropZone({
|
||||
getContainer: () => onAirWrap.current!,
|
||||
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||
});
|
||||
}
|
||||
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||
dropZonesDone.current.onair = true;
|
||||
onAirApi.current.addRowDropZone({
|
||||
getContainer: () => rosterWrap.current!,
|
||||
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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 */ }
|
||||
}, []);
|
||||
|
||||
// ---- Mic pass order ------------------------------------------------------
|
||||
// The on-air grid shows the stations in the order the mic goes around — NOT
|
||||
// by log time. The order is owned here as a list of draft ids: new check-ins
|
||||
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
|
||||
// buttons move the selected station. Persisted per net so a panel remount
|
||||
// (tab switch) keeps the round.
|
||||
const [orderIds, setOrderIds] = useState<number[]>([]);
|
||||
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
|
||||
// Load the saved order when a net opens.
|
||||
useEffect(() => {
|
||||
if (!orderKey) { setOrderIds([]); return; }
|
||||
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
|
||||
catch { setOrderIds([]); }
|
||||
}, [orderKey]);
|
||||
// Reconcile with the live list: keep known ids in their order, append new
|
||||
// ones, drop the gone. Persist.
|
||||
useEffect(() => {
|
||||
setOrderIds((cur) => {
|
||||
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
|
||||
const liveSet = new Set(liveIds);
|
||||
const kept = cur.filter((id) => liveSet.has(id));
|
||||
const keptSet = new Set(kept);
|
||||
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
|
||||
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
|
||||
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||
return next;
|
||||
});
|
||||
}, [active, orderKey]);
|
||||
const activeOrdered = useMemo(() => {
|
||||
const byId = new Map(active.map((a) => [a.id as number, a]));
|
||||
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
|
||||
}, [active, orderIds]);
|
||||
// Move the selected on-air station up/down the queue.
|
||||
const moveSelected = useCallback((dir: -1 | 1) => {
|
||||
const id = selectedActiveIds[0];
|
||||
if (id == null) return;
|
||||
setOrderIds((cur) => {
|
||||
const i = cur.indexOf(id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= cur.length) return cur;
|
||||
const next = [...cur];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||
return next;
|
||||
});
|
||||
}, [selectedActiveIds, orderKey]);
|
||||
|
||||
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).
|
||||
// Then auto-select the NEXT station in the mic-pass order, so the operator
|
||||
// can chain "log → log → log" without re-clicking a row each time.
|
||||
async function deactivate(id?: number) {
|
||||
if (id == null) return;
|
||||
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||
try {
|
||||
await NetDeactivate(id);
|
||||
await refreshActive();
|
||||
onLogged?.();
|
||||
if (next?.id != null) {
|
||||
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
|
||||
setSelectedActiveIds([next.id as number]);
|
||||
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
|
||||
}
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||
activateRef.current = activate;
|
||||
deactivateRef.current = deactivate;
|
||||
|
||||
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||
// exactly as the single-log button would.
|
||||
async function logAll() {
|
||||
if (active.length === 0) return;
|
||||
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
|
||||
try {
|
||||
for (const a of [...active]) {
|
||||
if (a.id != null) await NetDeactivate(a.id as number);
|
||||
}
|
||||
await refreshActive();
|
||||
onLogged?.();
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
|
||||
}
|
||||
|
||||
// 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 rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||
{ 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 ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||
<RecentQSOsGrid
|
||||
rows={activeOrdered}
|
||||
total={activeOrdered.length}
|
||||
storageKey="net.onair"
|
||||
selectRowSignal={selectRow}
|
||||
rowDragCall
|
||||
passOrder
|
||||
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||
onRowSelected={setSelectedActiveIds}
|
||||
/>
|
||||
</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={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||
</Button>
|
||||
{/* Mic pass order: move the selected station up/down the round. */}
|
||||
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
|
||||
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
|
||||
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
|
||||
onClick={() => moveSelected(-1)}>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
|
||||
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
|
||||
onClick={() => moveSelected(1)}>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||
</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 flex flex-col">
|
||||
{!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>
|
||||
) : (
|
||||
<RecentQSOsGrid
|
||||
rows={(wb.entries ?? []) as any}
|
||||
total={wb.count}
|
||||
storageKey="net.wb"
|
||||
{...qsoMenuHandlers}
|
||||
/>
|
||||
)}
|
||||
</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 ref={rosterWrap} 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 }}
|
||||
onGridReady={() => wireDropZones()}
|
||||
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,41 +1,74 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } 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;
|
||||
new_dxcc: boolean; new_band: boolean; new_mode: 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' },
|
||||
];
|
||||
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
{ v: '_', label: '— leave —' },
|
||||
{ v: 'Y', label: 'Yes' },
|
||||
{ v: 'N', label: 'No' },
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'I', label: 'Ignore' },
|
||||
{ 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;
|
||||
@@ -44,13 +77,14 @@ type LogQSO = {
|
||||
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 {
|
||||
@@ -76,6 +110,7 @@ export function fmtQslDate(s?: string): string {
|
||||
// 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
|
||||
@@ -103,13 +138,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
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('Y');
|
||||
const [qslRcvd, setQslRcvd] = useState('N');
|
||||
const [qslSent, setQslSent] = useState('_');
|
||||
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||
const [qslSentDate, setQslSentDate] = useState('');
|
||||
const [qslVia, setQslVia] = useState('');
|
||||
const [qslVia, setQslVia] = useState('_');
|
||||
const [qslNotes, setQslNotes] = useState('');
|
||||
const [qslComment, setQslComment] = useState('');
|
||||
|
||||
const searchPaper = useCallback(async () => {
|
||||
const c = paperCall.trim().toUpperCase();
|
||||
@@ -120,14 +158,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
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]);
|
||||
|
||||
function togglePaper(id: number) {
|
||||
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
}
|
||||
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
|
||||
|
||||
async function applyPaper() {
|
||||
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
||||
@@ -140,16 +176,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
||||
sent_date: ymd(qslSentDate),
|
||||
rcvd_date: ymd(qslRcvdDate),
|
||||
via: qslVia,
|
||||
via: qslVia === '_' ? '' : qslVia,
|
||||
notes: qslNotes,
|
||||
comment: qslComment,
|
||||
} as any);
|
||||
setPaperMsg(`${n} QSO updated.`);
|
||||
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);
|
||||
@@ -167,28 +208,42 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
|
||||
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
|
||||
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
|
||||
const [stats, setStats] = useState<any>(
|
||||
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
|
||||
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
|
||||
useEffect(() => { refreshCounts(); }, [refreshCounts]);
|
||||
|
||||
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} —`]);
|
||||
setBusy(false);
|
||||
refreshCounts(); // a download may have added confirmations
|
||||
});
|
||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||
setConfirmations((list ?? []) as Confirmation[]);
|
||||
setViewMode('confirmations');
|
||||
refreshCounts();
|
||||
});
|
||||
return () => { offLog(); offDone(); offConf(); };
|
||||
}, []);
|
||||
}, [refreshCounts]);
|
||||
|
||||
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]);
|
||||
|
||||
// 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 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
|
||||
case 'dxcc': return c.new_dxcc;
|
||||
case 'band': return c.new_band;
|
||||
case 'mode': return c.new_mode;
|
||||
case 'slot': return c.new_slot;
|
||||
default: return true;
|
||||
}
|
||||
@@ -199,33 +254,24 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
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)));
|
||||
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);
|
||||
}
|
||||
}, [service, sent]);
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
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([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||
try { await UploadQSOsManual(service, ids); }
|
||||
@@ -253,18 +299,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
{/* 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">Service</label>
|
||||
<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.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<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">Callsign</label>
|
||||
<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="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
||||
title={t('qslm.callsignScopeTitle')}
|
||||
>
|
||||
{uploadCall}
|
||||
</span>
|
||||
@@ -274,142 +320,143 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<>
|
||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||
Sync hunter log
|
||||
{t('qslm.syncHunterLog')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
|
||||
<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)} />
|
||||
Only my profile callsign
|
||||
{t('qslm.onlyMyCall')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
|
||||
<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)} />
|
||||
Add not-found QSOs to my log
|
||||
{t('qslm.addMissing')}
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground self-center">Token in Settings → External services → POTA.</span>
|
||||
<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">Callsign</label>
|
||||
<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="e.g. DL1ABC" />
|
||||
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" />}
|
||||
Search
|
||||
{t('qslm.search')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
|
||||
<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">Sent status</label>
|
||||
<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}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<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" />}
|
||||
Select required
|
||||
{t('qslm.selectRequired')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
|
||||
per-class breakdown so the totals are checkable. */}
|
||||
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
|
||||
<span className="font-semibold">{stats.slots_worked}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
|
||||
<span className="font-semibold">{stats.dxcc_worked}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
|
||||
</span>
|
||||
</div>
|
||||
{service === 'pota' && potaRes && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched}
|
||||
{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">{paperRows.length} QSO · {paperSel.size} selected</span>
|
||||
<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">Filter</label>
|
||||
<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">All</SelectItem>
|
||||
<SelectItem value="new">New (any)</SelectItem>
|
||||
<SelectItem value="dxcc">New DXCC</SelectItem>
|
||||
<SelectItem value="band">New band</SelectItem>
|
||||
<SelectItem value="slot">New slot</SelectItem>
|
||||
<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="mode">{t('qslm.filterNewMode')}</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" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
|
||||
{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'
|
||||
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
|
||||
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content: log OR results grid */}
|
||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||
{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">Search a callsign to list its QSOs, then set QSL status below.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.paperEmpty')}</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={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></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">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paperRows.map((r) => (
|
||||
<tr key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => togglePaper(r.id)}>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(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 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
// 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">Click “Sync hunter log” to fetch your pota.app log and stamp park references.</div>
|
||||
<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" /> Syncing with pota.app…</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-emerald-300 bg-emerald-50 text-emerald-800">
|
||||
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
|
||||
<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 && (
|
||||
<> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}.</>
|
||||
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
||||
)}
|
||||
{' '}Rescan the POTA award to count the new references.
|
||||
{' '}{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">Activator</th><th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
|
||||
<th className="py-1.5 px-2">Why unmatched</th>
|
||||
<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>
|
||||
@@ -417,7 +464,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<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 ? 'Open this QSO to fix it' : ''}>
|
||||
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>
|
||||
@@ -437,27 +484,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
) : 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(
|
||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
||||
/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" /> working…</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>
|
||||
) : viewMode === 'confirmations' ? (
|
||||
shownConfs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
|
||||
{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">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">New?</th>
|
||||
<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>
|
||||
@@ -469,9 +516,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<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-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
|
||||
{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_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</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>
|
||||
@@ -480,35 +528,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.uploadEmpty')}</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>
|
||||
<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>
|
||||
|
||||
@@ -516,33 +545,44 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
{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">QSL received</label>
|
||||
<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}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<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="QSL received date" />
|
||||
<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">QSL sent</label>
|
||||
<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}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<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="QSL sent date" />
|
||||
<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">Via</label>
|
||||
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
|
||||
<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}>
|
||||
Apply to {paperSel.size} selected
|
||||
{t('qslm.applyToSelected', { n: paperSel.size })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -552,18 +592,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<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="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
||||
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="How far back to download">
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="last">Since last download</SelectItem>
|
||||
<SelectItem value="date">Since date…</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<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' && (
|
||||
@@ -572,16 +612,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
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' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
|
||||
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="Insert confirmed QSOs that aren't in your log yet">
|
||||
<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)} />
|
||||
Add not-found
|
||||
{t('qslm.addNotFound')}
|
||||
</label>
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-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;
|
||||
|
||||
@@ -12,32 +13,39 @@ type Props = {
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||
{ service: 'clublog', label: 'Send to Club Log' },
|
||||
{ service: 'lotw', label: 'Send to LoTW' },
|
||||
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. Closes on any outside click, scroll or Escape.
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
||||
// 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, onExportSelectedFields, 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('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
@@ -55,29 +63,29 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{n} QSO{n > 1 ? 's' : ''} selected
|
||||
{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>Fix country & zones from cty.dat</span>
|
||||
<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-sky-600" />
|
||||
<span>Update from QRZ.com</span>
|
||||
<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-violet-600" />
|
||||
<span>Update from ClubLog (exceptions)</span>
|
||||
<BadgeCheck className="size-4 text-info" />
|
||||
<span>{t('qctx.updateClublog')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -89,8 +97,8 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
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-amber-600" />
|
||||
<span>Send OpsLog QSL by e-mail</span>
|
||||
<Mail className="size-4 text-warning" />
|
||||
<span>{t('qctx.sendQslEmail')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onSendRecording && (
|
||||
@@ -98,13 +106,26 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
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-rose-600" />
|
||||
<span>Send recording by e-mail</span>
|
||||
<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" />
|
||||
@@ -113,8 +134,17 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
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-sky-600" />
|
||||
<span>Export selected to ADIF ({n})</span>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportSelectedFields && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportSelectedFields', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportFiltered && (
|
||||
@@ -122,8 +152,26 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
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-violet-600" />
|
||||
<span>Export filtered view to ADIF (no limit)</span>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
@@ -132,18 +180,31 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
{onSendTo && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{UPLOAD_TARGETS.map((t) => (
|
||||
{UPLOAD_TARGETS.map((target) => (
|
||||
<button
|
||||
key={t.service}
|
||||
key={target.service}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendTo(t.service, menu.ids); onClose(); }}
|
||||
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
||||
>
|
||||
<Upload className="size-4 text-emerald-600" />
|
||||
<span>{t.label}</span>
|
||||
<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,6 +1,7 @@
|
||||
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 { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
@@ -20,6 +21,7 @@ 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;
|
||||
@@ -37,12 +39,13 @@ function pfxOf(call: string): string {
|
||||
|
||||
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'];
|
||||
|
||||
@@ -63,20 +66,38 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
{ 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>;
|
||||
}
|
||||
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
|
||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
||||
: v === 'R' ? 'bg-orange-400 text-white'
|
||||
: v === 'I' ? 'bg-stone-400 text-white'
|
||||
: 'bg-amber-400 text-amber-950';
|
||||
// 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>;
|
||||
}
|
||||
|
||||
@@ -128,17 +149,19 @@ 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, 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.
|
||||
@@ -153,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
return base;
|
||||
}, [modes, qso.mode]);
|
||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
|
||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||
useEffect(() => {
|
||||
GetListsSettings()
|
||||
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
// 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') }
|
||||
@@ -177,6 +207,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// 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.
|
||||
@@ -197,6 +230,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
.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 */ }
|
||||
})
|
||||
@@ -215,6 +249,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
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 }));
|
||||
}
|
||||
@@ -234,7 +276,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||
async function fetchLookup() {
|
||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||
if (!call) { setLocalErr('Callsign required'); return; }
|
||||
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
@@ -258,14 +300,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
lon: r.lon || d.lon,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
||||
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 = {
|
||||
@@ -309,7 +351,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// 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);
|
||||
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
|
||||
onSave(out);
|
||||
}
|
||||
|
||||
@@ -329,25 +371,25 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
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="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||
<TabsList className="px-3 overflow-x-auto">
|
||||
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
||||
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
||||
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
||||
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||
<TabsTrigger value="moreadif">More ADIF</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">
|
||||
ADIF fields
|
||||
{t('qedit.tabAdifFields')}
|
||||
{extrasCount > 0 && (
|
||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||
)}
|
||||
@@ -365,48 +407,48 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Top: Callsign + RST + Fetch */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label>Callsign</Label>
|
||||
<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)} />
|
||||
</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>
|
||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></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>
|
||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
||||
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>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<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">Band</Label>
|
||||
<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">RX Band</Label>
|
||||
<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">Mode</Label>
|
||||
<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">Country</Label>
|
||||
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
||||
<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">
|
||||
@@ -414,16 +456,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<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="DXCC entity # — set automatically from Country" />
|
||||
<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">Freq</Label>
|
||||
<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">RX Freq</Label>
|
||||
<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>
|
||||
@@ -431,7 +473,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* ── Right column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||
<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) => {
|
||||
@@ -440,16 +482,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// 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);
|
||||
}} /> QSO End (UTC)
|
||||
}} /> {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>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<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>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></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>
|
||||
@@ -458,10 +500,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<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>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||
<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>Continent</Label>
|
||||
<Label>{t('qedit.continent')}</Label>
|
||||
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -471,17 +513,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', 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>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||
<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>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
<div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></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>
|
||||
@@ -495,13 +537,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* 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">Computed (automatic)</span>
|
||||
<span className="text-xs font-semibold">{t('qedit.computedAuto')}</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
||||
{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">None yet.</div>
|
||||
<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">
|
||||
@@ -525,44 +567,48 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
<div className="flex-1 max-w-sm space-y-3">
|
||||
<div>
|
||||
<Label>Manage Confirmation</Label>
|
||||
<Label>{t('qedit.manageConf')}</Label>
|
||||
<Select value={confSel} onValueChange={setConfSel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
|
||||
<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>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>Received</Label>
|
||||
<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>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></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>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
|
||||
<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">
|
||||
Pick a channel, edit it — the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right: live status grid for every channel */}
|
||||
<div className="w-72 shrink-0">
|
||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
||||
{/* 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">Type</th>
|
||||
<th className="font-semibold">Sent</th>
|
||||
<th className="font-semibold">Received</th>
|
||||
<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={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
|
||||
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
||||
<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>
|
||||
@@ -577,90 +623,88 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
<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}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="Country" onChange={(v) => set('my_country', v)} /></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="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">Special activity</p>
|
||||
<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="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||
<F label="WWFF ref" 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="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', 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">Power & space weather</p>
|
||||
<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="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<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">Identity & clubs</p>
|
||||
<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="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||
<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>
|
||||
@@ -670,28 +714,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* Flags & credits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags & credits</p>
|
||||
<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="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||
<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="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', 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">My station (ADIF)</p>
|
||||
<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="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<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>
|
||||
@@ -704,11 +748,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
<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,10 +1,11 @@
|
||||
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, FilterX } from 'lucide-react';
|
||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import {
|
||||
@@ -13,39 +14,41 @@ import {
|
||||
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;
|
||||
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||
// Control auto-advancing to the next on-air station after logging one).
|
||||
selectRowSignal?: { id: number; seq: number };
|
||||
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||
// register external drop zones via api.addRowDropZone.
|
||||
rowDragCall?: boolean;
|
||||
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
|
||||
// is the display order — a pinned "#" column numbers it, and all column
|
||||
// sorting is disabled (including saved sorts) so the queue can't be shuffled
|
||||
// by a header click. The parent owns/reorders the array.
|
||||
passOrder?: boolean;
|
||||
onGridApi?: (api: any) => void;
|
||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||
// reuse this grid elsewhere with its OWN independent column config.
|
||||
storageKey?: string;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
@@ -53,11 +56,23 @@ type Props = {
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||
onFilteredCountChange?: (count: number | null) => 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';
|
||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||
|
||||
function fmtMhzDots(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
@@ -92,125 +107,138 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
||||
|
||||
// Shared so the Worked-before grid (which now also shows full QSO records)
|
||||
// can offer the exact same column choices without duplicating the catalog.
|
||||
export const COL_CATALOG: ColEntry[] = [
|
||||
// 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: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', 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: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', 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: '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: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', 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 },
|
||||
{ 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 (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: 'ClubLog sent', colId: 'clublog_qso_upload_status', headerName: 'ClubLog sent', field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'ClubLog sent date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog S date', field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ 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) },
|
||||
];
|
||||
|
||||
export const GROUP_ORDER = [
|
||||
@@ -218,10 +246,36 @@ export const GROUP_ORDER = [
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: 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);
|
||||
|
||||
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
||||
// Grid's saved column state. Stripping them here (on both save and restore)
|
||||
// stops a stale saved state from re-hiding a shown award column on every
|
||||
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
// Column-layout persistence keys — scoped by storageKey so a reused instance
|
||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||
|
||||
// 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.
|
||||
@@ -244,52 +298,200 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
// 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) => {
|
||||
// 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 = storageKey ? `hamlog.awardColsShown.${storageKey}` : '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 col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||
col.rowDrag = true;
|
||||
}
|
||||
if (passOrder) {
|
||||
delete (col as any).sort; // e.g. the date column's default 'desc'
|
||||
col.sortable = false;
|
||||
}
|
||||
return col;
|
||||
});
|
||||
if (passOrder) {
|
||||
base.unshift({
|
||||
colId: '__order', headerName: '#', width: 46, pinned: 'left',
|
||||
sortable: false, resizable: false, suppressMovable: true, filter: false,
|
||||
cellClass: 'font-mono text-muted-foreground tabular-nums',
|
||||
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
|
||||
} as ColDef<QSOForm>);
|
||||
}
|
||||
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, rowDragCall, passOrder]);
|
||||
|
||||
// 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,
|
||||
sortable: !passOrder,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
suppressMovable: false,
|
||||
}), []);
|
||||
}), [passOrder]);
|
||||
|
||||
// In pass-order mode a saved sort (from before the mode existed, or synced
|
||||
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
|
||||
const sanitizeState = useCallback((state: any[]) => (
|
||||
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
|
||||
), [passOrder]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
onGridApi?.(e.api);
|
||||
const local = loadLocal(colStateKey);
|
||||
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(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) => {
|
||||
loadRemote(colStateKey).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
|
||||
seedLocal(colStateKey, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||
const api = e?.api ?? gridRef.current?.api;
|
||||
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
|
||||
// row node across a reorder (getRowId by id), so the number is cached and
|
||||
// stale until we force it. Refresh it on every model update (a reorder
|
||||
// fires one). force:true bypasses the value cache.
|
||||
if (passOrder && api?.refreshCells) {
|
||||
api.refreshCells({ columns: ['__order'], force: true });
|
||||
}
|
||||
if (!api || !onFilteredCountChange) return;
|
||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||
}, [onFilteredCountChange, passOrder]);
|
||||
const saveColumnState = useCallback(() => {
|
||||
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);
|
||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||
}, []);
|
||||
|
||||
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(colStateKey);
|
||||
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(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, awardShown]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
const api = gridRef.current?.api;
|
||||
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
setSelCount(sel.length);
|
||||
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||
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]);
|
||||
|
||||
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
||||
// tick so a row-data refresh in the same render settles first.
|
||||
useEffect(() => {
|
||||
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
||||
const t = window.setTimeout(() => {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.deselectAll();
|
||||
api.forEachNode((n: any) => {
|
||||
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
||||
});
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [selectRowSignal?.seq]);
|
||||
|
||||
// ── 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);
|
||||
@@ -317,17 +519,46 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
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">
|
||||
{/* Live selection count — visible without opening the context menu. */}
|
||||
{selCount > 0 && (
|
||||
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||
{t('rqg.selectedCount', { n: selCount })}
|
||||
</span>
|
||||
)}
|
||||
{/* Select every loaded row that passes the active column filters — so a
|
||||
filtered view can be selected in one click (then send to LoTW, bulk
|
||||
edit, export…). Once everything is selected the same button flips to
|
||||
"Unselect all". */}
|
||||
{(() => {
|
||||
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||
onClick={() => {
|
||||
const api: any = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
if (allSelected) api.deselectAll();
|
||||
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||
else api.selectAll('filtered');
|
||||
}}>
|
||||
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
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' }}>
|
||||
@@ -340,12 +571,15 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onGridReady={onGridReady}
|
||||
onFilterChanged={reportFilteredCount}
|
||||
onModelUpdated={reportFilteredCount}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||
onSelectionChanged={onSelectionChanged}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
preventDefaultOnContextMenu
|
||||
@@ -365,17 +599,21 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
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 px-5 py-3">
|
||||
@@ -385,10 +623,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
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">
|
||||
@@ -405,10 +643,30 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
</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>
|
||||
|
||||
@@ -75,22 +75,22 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
<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-emerald-500' : 'bg-muted-foreground/40')}
|
||||
<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-amber-200 text-amber-900'
|
||||
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
|
||||
: 'bg-emerald-200 text-emerald-900')}
|
||||
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-emerald-700 tabular-nums">
|
||||
<span className="font-mono text-sm font-bold text-success tabular-nums">
|
||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||
</span>
|
||||
{onClose && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
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;
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
// 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('');
|
||||
@@ -56,8 +58,8 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
async function send() {
|
||||
const c = call.trim().toUpperCase();
|
||||
const f = parseFloat(freqKHz);
|
||||
if (!c) { setError('Callsign required'); return; }
|
||||
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
|
||||
if (!c) { setError(t('spm.callRequired')); return; }
|
||||
if (!f || f <= 0) { setError(t('spm.freqRequired')); return; }
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -84,25 +86,25 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Satellite className="size-4 text-primary" /> Send DX Spot
|
||||
<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">Callsign</Label>
|
||||
<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="DX call"
|
||||
placeholder={t('spm.callPh')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1">Frequency (kHz)</Label>
|
||||
<Label className="mb-1">{t('spm.frequency')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={freqKHz}
|
||||
@@ -113,18 +115,18 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">Message</Label>
|
||||
<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="e.g. CW · TNX QSO"
|
||||
placeholder={t('spm.messagePh')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1 block">Latest QSOs</Label>
|
||||
<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
|
||||
@@ -142,17 +144,17 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-rose-600">{error}</div>}
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
||||
{ok ? 'Spot sent ✓' : targetName ? `→ ${targetName}` : 'Master cluster'}
|
||||
{ok ? t('spm.spotSent') : targetName ? `→ ${targetName}` : t('spm.masterCluster')}
|
||||
</span>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<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 ? 'Sending…' : 'Send spot'}
|
||||
{busy ? t('spm.sending') : t('spm.sendSpot')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
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,651 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } 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 { AmpCard } from '@/components/AmpCard';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
} 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; channels?: number; 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, denkovi: 8, usbrelay: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||
|
||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||
// the generic USB-serial board, whose channel count the user picks.
|
||||
const chanCount = (d: Device): number =>
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
|
||||
|
||||
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||
|
||||
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||
// 1 = driven element, then Director 1, 2, 3…
|
||||
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||
if (i === 0) return t('station.reflector');
|
||||
if (i === 1) return t('station.driven');
|
||||
return `${t('station.director')} ${i - 1}`;
|
||||
}
|
||||
|
||||
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||||
const [err, setErr] = useState('');
|
||||
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||
const [reading, setReading] = useState(false);
|
||||
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||
const [editVal, setEditVal] = useState('');
|
||||
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
const isUB = ant.type !== 'steppir';
|
||||
|
||||
const readLengths = useCallback(async () => {
|
||||
setReading(true); setErr('');
|
||||
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setReading(false); }
|
||||
}, []);
|
||||
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||
// +/- starts from the real values rather than blind.
|
||||
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||
|
||||
// Send an absolute length to one element and remember it as the new baseline.
|
||||
const setLen = async (i: number, mm: number) => {
|
||||
const next = Math.max(0, Math.round(mm));
|
||||
const prev = lengths[i] ?? 0;
|
||||
setBusyEl(i); setErr('');
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||
try { await MotorSetElement(i, next); refetch(); }
|
||||
catch (e: any) {
|
||||
// Rejected by the controller — most often the element is at its travel
|
||||
// limit for this band (extending on a low band). Revert the optimistic
|
||||
// value so the display stays truthful, and explain the likely cause when
|
||||
// the failed move was an extension.
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||
}
|
||||
finally { setBusyEl(null); }
|
||||
};
|
||||
// Nudge one element by ±2 mm from its current known length.
|
||||
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||
const commitEdit = (i: number) => {
|
||||
const v = parseInt(editVal, 10);
|
||||
setEditingEl(null);
|
||||
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||
};
|
||||
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||
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">
|
||||
<AntennaIcon className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||
</div>
|
||||
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||||
<div className="flex gap-1">
|
||||
{dirs.map(([d, lbl]) => (
|
||||
<button key={d} type="button" disabled={!ant.connected}
|
||||
onClick={() => run(SetUltrabeamDirection(d))}
|
||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
|
||||
{isUB && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||
</button>
|
||||
</div>
|
||||
{lengths.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||
up structural bytes past the real data (varying by band), which
|
||||
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
{editingEl === i ? (
|
||||
<input autoFocus type="number" value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
onBlur={() => commitEdit(i)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||
) : (
|
||||
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||
title={t('station.setExactLen')}
|
||||
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="text-[11px] text-destructive break-words">{err}</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 });
|
||||
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
|
||||
// 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);
|
||||
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||
// row so cards wrap onto further lines even when there's horizontal room.
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||
// FlexRadio meters read live; the Flex state rides along because a PGXL's
|
||||
// OPERATE/meters come from the radio, not the direct GSCP link.
|
||||
const [amps, setAmps] = useState<any[]>([]);
|
||||
const [flexState, setFlexState] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => Promise.all([
|
||||
GetAmpStatuses().catch(() => []),
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
|
||||
load();
|
||||
const id = window.setInterval(load, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
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 */ }
|
||||
}, []);
|
||||
const pollAnt = useCallback(async () => {
|
||||
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||
useEffect(() => {
|
||||
poll(); pollRot(); pollAnt();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [poll, pollRot, pollAnt, 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>
|
||||
{/* Compact one-line relay buttons at a FIXED width so they don't stretch
|
||||
across the whole card — they wrap to fill the available width instead. */}
|
||||
<div className="p-2 flex flex-wrap gap-1.5">
|
||||
{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}
|
||||
title={label}
|
||||
onClick={() => toggle(dev, r.number, !r.on)}
|
||||
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 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-5 rounded shrink-0',
|
||||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
|
||||
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
|
||||
{r.on ? t('station.on') : t('station.off')}
|
||||
</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} /> });
|
||||
}
|
||||
if (ant.enabled) {
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
}
|
||||
// One card per configured amplifier (identical to the Flex panel's card).
|
||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} 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 && !ant.enabled && amps.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||
card can sit on a further line even with horizontal room to spare. */}
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{c === 'auto' ? t('station.colsAuto') : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed
|
||||
column count caps the container width so cards wrap onto more lines. Each
|
||||
card has a grip handle (left rail) as the drag initiator (the card body is
|
||||
full of buttons, so dragging the whole card was unreliable). */}
|
||||
<div className="flex flex-wrap gap-4 items-start"
|
||||
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}>
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id} className="flex items-stretch w-[430px]"
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">{w.node}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!noDevices && (
|
||||
<p className="text-[11px] text-muted-foreground mt-2">{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 channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||
const n = chanCount({ ...device, type, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, channels, labels });
|
||||
};
|
||||
const setChannels = (channels: number) => {
|
||||
const n = chanCount({ ...device, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, channels, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!isUsbRelay) return;
|
||||
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||
}, [isUsbRelay]);
|
||||
// Detected FTDI serials for the Denkovi picker.
|
||||
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectMsg, setDetectMsg] = useState('');
|
||||
const detectDenkovi = async () => {
|
||||
setDetecting(true);
|
||||
setDetectMsg('');
|
||||
try {
|
||||
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||
setDenkoviSerials(list);
|
||||
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
|
||||
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
|
||||
finally { setDetecting(false); }
|
||||
};
|
||||
// Connection test result for the current device config.
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const testDevice = async () => {
|
||||
setTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const r: any = await TestStationDevice(device as any);
|
||||
setTestMsg(r?.ok
|
||||
? { ok: true, text: t('station.testOk', { n: r.relays }) }
|
||||
: { ok: false, text: r?.error || t('station.testFail') });
|
||||
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||
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>
|
||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</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>
|
||||
{(isDenkovi || isUsbRelay) && (
|
||||
<div className="space-y-1 max-w-[10rem]">
|
||||
<Label>{t('station.channels')}</Label>
|
||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isDenkovi ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.ftdiSerial')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||
list="denkovi-serials"
|
||||
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
<datalist id="denkovi-serials">
|
||||
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
{t('station.detect')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||||
</div>
|
||||
) : isUsbRelay ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.comPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</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 items-center gap-2">
|
||||
{testMsg && (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||
{testMsg.text}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||
{t('station.test')}
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||
import { GetLogStats, GetContestRuns, GetOperators } 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 BandCat = { band: string; cw: number; phone: number; data: number; total: 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_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: 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, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||
return (
|
||||
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||
{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. Each tile
|
||||
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||
// stays in high-contrast foreground ink so it reads on every theme.
|
||||
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||
<div className="relative">
|
||||
<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>
|
||||
</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, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||
// 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, i) => (
|
||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{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: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||
/>
|
||||
</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, showValues, colorful }: { data: Bucket[]; empty: string; height?: number; showValues?: boolean; colorful?: boolean }) {
|
||||
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={cn('text-[9px] font-semibold mb-0.5 tabular-nums whitespace-nowrap',
|
||||
showValues ? 'text-foreground' : 'text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity')}>
|
||||
{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: colorful ? `var(--chart-${(i % 8) + 1})` : '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>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||
// categories are the subject → categorical colour in a fixed order, matching the
|
||||
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||
const BAND_SPLIT = [
|
||||
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||
] as const;
|
||||
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||
{data.map((d) => (
|
||||
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||
{BAND_SPLIT.map((s) => {
|
||||
const v = d[s.key];
|
||||
if (!v) return null;
|
||||
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||
})}
|
||||
</div>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||
{BAND_SPLIT.map((s) => (
|
||||
<span key={s.key} className="inline-flex items-center gap-1">
|
||||
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||
</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 | ||||