Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9fddbc5b | ||
|
|
74acf88976 | ||
|
|
da9c76e161 | ||
|
|
fb78b1c052 | ||
|
|
e51abd262e | ||
|
|
0ec855d95a | ||
|
|
ae06495f91 | ||
|
|
4dd2c3b997 | ||
|
|
ec71dd1661 | ||
|
|
31f9bdfc98 | ||
|
|
57e98139ab | ||
|
|
b10a867125 | ||
|
|
17819ea673 | ||
|
|
a8fac52400 | ||
|
|
2bf98a9801 | ||
|
|
80dac64b56 | ||
|
|
79427ccd18 | ||
|
|
4352b9aec5 | ||
|
|
a0f7f2abf0 | ||
|
|
40f5960c76 | ||
|
|
59135d55ab | ||
|
|
44cf5954fd | ||
|
|
f1b7a7e477 | ||
|
|
19ae00124f | ||
|
|
642ed358c2 | ||
|
|
1d8cd25205 | ||
|
|
1f55cfe9fa | ||
|
|
90c6458af0 | ||
|
|
18f44a5aa3 | ||
|
|
4d13cf7d15 | ||
|
|
366d9df634 | ||
|
|
f4b7c9dcf8 | ||
|
|
48c448e6f3 | ||
|
|
9c757e5175 | ||
|
|
626edca8ba | ||
|
|
b202d98ae5 | ||
|
|
949cc17ed1 | ||
|
|
e9feeffdc3 | ||
|
|
7d7d1042c0 | ||
|
|
9cbfd39da0 | ||
|
|
5338ad0b29 | ||
|
|
970127cc16 | ||
|
|
3704dbdd99 | ||
|
|
3cdf16b501 | ||
|
|
9a72afd467 | ||
|
|
e2bfe73bdb | ||
|
|
6e2e2cc3aa | ||
|
|
75d11a4069 | ||
|
|
cfe5d6f2a7 | ||
|
|
78c289d681 | ||
|
|
761f554ac0 | ||
|
|
4c1b4fd1a8 | ||
|
|
f2cc4f11cd | ||
|
|
faf084ddfc | ||
|
|
01dcd91253 | ||
|
|
3d603d34aa | ||
|
|
3d1602c4bd | ||
|
|
a10fb2413a | ||
|
|
bd8ca2e9de | ||
|
|
df56391eb8 | ||
|
|
82ce3353c4 | ||
|
|
20dc3e83a6 | ||
|
|
62cdabe114 | ||
|
|
9bfb44925b | ||
|
|
fb1af9b6a2 | ||
|
|
57a9bed145 | ||
|
|
fbd323af26 | ||
|
|
689d1cc902 | ||
|
|
67602fd485 | ||
|
|
2de5bce998 | ||
|
|
f0959c796d | ||
|
|
16e64fd7b7 | ||
|
|
7e696a72bb | ||
|
|
9af2732314 | ||
|
|
f82d92565f | ||
|
|
d0f25aea9b | ||
|
|
f91b83ee12 | ||
|
|
a554257d5f | ||
|
|
a20d9a0b76 | ||
|
|
92ee8aadde | ||
|
|
61c6666a4c | ||
|
|
9f5c482667 | ||
|
|
b9ea077a64 | ||
|
|
a48fa0b2e7 | ||
|
|
821883dfd3 | ||
|
|
ef087492cc | ||
|
|
98f11ee3d0 | ||
|
|
058f164ab6 | ||
|
|
6c47c27775 | ||
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 | ||
|
|
f27faa81a6 | ||
|
|
69e6c20b97 | ||
|
|
d93c40ebb3 | ||
|
|
dfe3afbf2d | ||
|
|
bb3542c920 | ||
|
|
a966abd9df | ||
|
|
95482ec1d8 | ||
|
|
500fe8bfec | ||
|
|
87ff0a9e16 | ||
|
|
18f9b915c5 | ||
|
|
b3fdd0b7fa | ||
|
|
9846354bf9 | ||
|
|
3da1d71323 | ||
|
|
72f692aa04 | ||
|
|
f6cd6e999a | ||
|
|
296a4a55c0 | ||
|
|
acf73c73c7 | ||
|
|
5777c119cb | ||
|
|
85bf0da006 | ||
|
|
b6ea07e3a3 | ||
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a | ||
|
|
d8b7b86eb7 | ||
|
|
3e1c36d186 | ||
|
|
6255a9f218 | ||
|
|
599621a998 | ||
|
|
d7bcf21203 | ||
|
|
589e0b9b6a | ||
|
|
c21d6cc23a | ||
|
|
ae21ddb9d7 | ||
|
|
a4623e9ea3 | ||
|
|
06e34372ed | ||
|
|
d23c4c4037 | ||
|
|
d4bfd30636 | ||
|
|
258fa717f1 | ||
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 | ||
|
|
5623b860c2 | ||
|
|
3e22c4d1a3 | ||
|
|
d90f953df4 | ||
|
|
e4217da010 | ||
|
|
c5ac945de0 | ||
|
|
eb40991482 | ||
|
|
781cf636ea | ||
|
|
62b6fe0a3a | ||
|
|
bd2a8524dc | ||
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 | ||
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 | ||
|
|
712a124081 | ||
|
|
08a932c9c1 | ||
|
|
fee2303625 | ||
|
|
4d5c5c3eb3 | ||
|
|
d322ea9a07 | ||
|
|
75c02ea2a0 | ||
|
|
ef84b04c99 | ||
|
|
fbd6cf400f | ||
|
|
b16cb973f2 | ||
|
|
da886de189 | ||
|
|
7759e09b4e | ||
|
|
a8990ff29d | ||
|
|
e3b810b4ff | ||
|
|
337392bb6d | ||
|
|
d98bb2e929 | ||
|
|
3ab6b95ff8 | ||
|
|
b0da6a3d14 | ||
|
|
2d7469a7f7 | ||
|
|
775f411606 | ||
|
|
6501e97895 | ||
|
|
f3807c21ba | ||
|
|
d536c39ef5 | ||
|
|
284a7a18c7 | ||
|
|
482f81fe45 | ||
|
|
5d7a9a9562 | ||
|
|
a036120871 | ||
|
|
ccceab9d02 | ||
|
|
35db1440e4 | ||
|
|
e3aabc06a4 | ||
|
|
ab9d0bfe0f | ||
|
|
a3fd32ec91 | ||
|
|
c297f91ca8 | ||
|
|
ee1f9ccf35 | ||
|
|
6fb7f06375 | ||
|
|
54caee37c4 | ||
|
|
15fdbce22a | ||
|
|
c7737aabd6 | ||
|
|
6f5acc2eef | ||
|
|
67a03c3ddc | ||
|
|
8da3a27803 | ||
|
|
e0cefb5c41 | ||
|
|
113faede14 | ||
|
|
8e491544dd | ||
|
|
cbe571a742 | ||
|
|
bdda7ad07a | ||
|
|
3fd6763ff0 | ||
|
|
a89c6e0df8 | ||
|
|
48d92ff547 | ||
|
|
adadb632fa | ||
|
|
b0b25c7f1f | ||
|
|
e120bd4f04 | ||
|
|
40df4fe22f | ||
|
|
d114a73729 | ||
|
|
d0666581c3 | ||
|
|
37dc2a07e5 | ||
|
|
7424bc6e81 | ||
|
|
01c93d979f | ||
|
|
0190255762 | ||
|
|
0cc806722e | ||
|
|
08f3e54afb | ||
|
|
520d17810c | ||
|
|
c9218310ae | ||
|
|
8cc6997eec | ||
|
|
6d309cada1 | ||
|
|
7153768579 | ||
|
|
df4155108f | ||
|
|
9bc5a14c69 | ||
|
|
842d4708a7 | ||
|
|
e2aba828a9 | ||
|
|
753d8d2ffa | ||
|
|
38b480a985 | ||
|
|
67005a8d50 | ||
|
|
9cfa7a4dd1 | ||
|
|
a1c4305f20 | ||
|
|
3b296b19ab | ||
|
|
8a0d76fa0c | ||
|
|
bd5f9c0746 | ||
|
|
29591c5f0c | ||
|
|
01bcf256e2 | ||
|
|
c9d1fc1cc0 | ||
|
|
f43d41892e | ||
|
|
1f2e86c04d | ||
|
|
305da583e1 | ||
|
|
71bde30e24 | ||
|
|
31b13cfbc0 | ||
|
|
30d88276f3 | ||
|
|
b18db7acfc | ||
|
|
d303dee768 | ||
|
|
ad02583ef7 | ||
|
|
add4b175fc | ||
|
|
11940ae843 | ||
|
|
91f046444a | ||
|
|
386fb7f030 | ||
|
|
5a6db2b656 | ||
|
|
c3958be0b2 | ||
|
|
a42a3d5713 | ||
|
|
0a1569dbbd | ||
|
|
843dccf0c4 | ||
|
|
d49126d45c | ||
|
|
cc1ad06a9d | ||
|
|
4dd15b09e9 | ||
|
|
4eec272c0b | ||
|
|
816a727e88 | ||
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 | ||
|
|
05d64024ef | ||
|
|
cb27aa5ebf | ||
|
|
aefb984974 | ||
|
|
91b5af1c7b | ||
|
|
4fd70f6a9d | ||
|
|
51e279887d | ||
|
|
7e08553e6e | ||
|
|
3564eecc36 | ||
|
|
3e7f3832e0 | ||
|
|
34b60f9f20 | ||
|
|
b7bfd39652 | ||
|
|
6a1103bf5f | ||
|
|
91653bca57 | ||
|
|
6be0f43dd0 | ||
|
|
370fde42f7 | ||
|
|
e5ff30823d | ||
|
|
9e86d57dac | ||
|
|
00bfee4ed2 | ||
|
|
f4bc55cd41 | ||
|
|
3a9dda13c4 | ||
|
|
9b677c6b35 | ||
|
|
933d601c03 | ||
|
|
b4f0e0bc29 | ||
|
|
9f384402fa | ||
|
|
18b69ee8b4 | ||
|
|
8c1b7af5b3 | ||
|
|
6e953ab1f4 | ||
|
|
88202efddb | ||
|
|
3f15608c59 | ||
|
|
e6a6f04ccf | ||
|
|
3b1a8ef01a | ||
|
|
fd097a647f | ||
|
|
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 |
@@ -46,3 +46,4 @@ cat.log
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
*.syso
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation.
|
||||
|
||||
**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
|
||||
wails build # full build → build/bin/OpsLog.exe (~25 s)
|
||||
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
|
||||
|
||||
go build ./... # fast Go-only check
|
||||
go test ./... # all Go tests
|
||||
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
|
||||
gofmt -w <file>
|
||||
|
||||
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
|
||||
```
|
||||
|
||||
Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* → *Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
|
||||
|
||||
Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
|
||||
|
||||
## Navigating app.go
|
||||
|
||||
`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections:
|
||||
|
||||
```bash
|
||||
grep -n "^// ──\|^// ---" app.go # table of contents
|
||||
```
|
||||
|
||||
Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
|
||||
|
||||
Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Wails binding boundary
|
||||
|
||||
Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless).
|
||||
|
||||
Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed.
|
||||
|
||||
### Two separate databases
|
||||
|
||||
This split is easy to get wrong and matters:
|
||||
|
||||
- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`.
|
||||
- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which.
|
||||
|
||||
QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository.
|
||||
|
||||
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining.
|
||||
|
||||
### Per-profile settings scoping
|
||||
|
||||
`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences:
|
||||
|
||||
- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
|
||||
- Settings keys are declared as `key<Thing>` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one.
|
||||
|
||||
Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured".
|
||||
|
||||
### Hardware device pattern
|
||||
|
||||
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
|
||||
|
||||
1. **`internal/<device>/`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`.
|
||||
2. **Settings** — `key<Thing>*` constants plus `Get<Thing>Settings()` / `Save<Thing>Settings()` bindings in `app.go`.
|
||||
3. **Lifecycle** — a `start<Thing>()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
|
||||
4. **Status binding** — `Get<Thing>Status()`, polled by the frontend.
|
||||
5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`.
|
||||
|
||||
`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna.
|
||||
|
||||
Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere.
|
||||
|
||||
### CAT
|
||||
|
||||
`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one.
|
||||
|
||||
### Logging
|
||||
|
||||
`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
|
||||
|
||||
## Conventions
|
||||
|
||||
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
||||
|
||||
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
|
||||
|
||||
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
||||
|
||||
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
||||
|
||||
**Commit messages must not include a `Co-Authored-By` line or any mention of the model.**
|
||||
|
||||
**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template.
|
||||
- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build).
|
||||
- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
|
||||
- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.
|
||||
+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 en
|
||||
bandeau unique, 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 unique :** 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 :** 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 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 +
|
||||
<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 — single-strip entry, real-time CAT
|
||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||
management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||
Fully themeable and bilingual (English / French).
|
||||
|
||||
Developed by **F4BPO**.
|
||||
|
||||
@@ -24,16 +31,25 @@ Developed by **F4BPO**.
|
||||
|
||||
## Logging
|
||||
|
||||
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||
- **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||
**flag** is shown large next to the RST fields.
|
||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||
and the QRZ.com tab.
|
||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||
with `/MM` `/AM` 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:** 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,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// keyAwardsTracked holds the PER-PROFILE list of award codes the operator wants
|
||||
// to follow (JSON array of award.Def.Code). Award definitions themselves are
|
||||
// global (keyAwardDefs, shared across profiles), but WHICH awards a station
|
||||
// tracks is a per-profile choice — a DX profile follows DXCC/WPX, a POTA profile
|
||||
// follows POTA/WWFF. An empty/unset list means "track them all" so the Awards
|
||||
// tab is never blank before the operator has picked anything.
|
||||
const keyAwardsTracked = "awards.tracked"
|
||||
|
||||
// GetTrackedAwards returns the active profile's followed award codes. An empty
|
||||
// slice means the operator has not narrowed the list — the Awards tab then shows
|
||||
// every award.
|
||||
func (a *App) GetTrackedAwards() ([]string, error) {
|
||||
out := []string{}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keyAwardsTracked)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveTrackedAwards persists the followed award codes for the active profile and
|
||||
// notifies the Awards tab to re-filter its list. Codes are stored verbatim; the
|
||||
// Awards tab intersects them with the live award definitions, so a code that no
|
||||
// longer exists is simply ignored (not an error).
|
||||
func (a *App) SaveTrackedAwards(codes []string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if codes == nil {
|
||||
codes = []string{}
|
||||
}
|
||||
b, err := json.Marshal(codes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyAwardsTracked, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
wruntime.EventsEmit(a.ctx, "awards:tracked-changed")
|
||||
return nil
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||
// the call is entered by the operator and can be changed — so this only turns
|
||||
// away straightforward use, not a determined one.
|
||||
var deniedCallHashes = map[string]struct{}{
|
||||
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
|
||||
}
|
||||
|
||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||
func callDenied(call string) bool {
|
||||
base := denyBaseCall(call)
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(base))
|
||||
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||
return bad
|
||||
}
|
||||
|
||||
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||
func denyBaseCall(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if !strings.Contains(s, "/") {
|
||||
return s
|
||||
}
|
||||
best := ""
|
||||
for _, part := range strings.Split(s, "/") {
|
||||
if len(part) > len(best) {
|
||||
best = part
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||
// launch is where the operator enters and saves the call, so a denied build
|
||||
// simply does not come back up on the next launch. No window, no message.
|
||||
func enforceCallGate(calls ...string) {
|
||||
for _, c := range calls {
|
||||
if callDenied(c) {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-21
@@ -31,13 +31,20 @@ import (
|
||||
const (
|
||||
keyQSLEmailSubject = "qsl.email_subject"
|
||||
keyQSLEmailBody = "qsl.email_body"
|
||||
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
|
||||
)
|
||||
|
||||
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
|
||||
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
|
||||
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
||||
|
||||
// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
|
||||
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
|
||||
// the card: received → "TNX QSL" (thanks for your card), not received → "PSE QSL"
|
||||
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
||||
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
||||
|
||||
const (
|
||||
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||||
@@ -122,12 +129,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,28 +444,53 @@ 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)
|
||||
return nil
|
||||
}
|
||||
|
||||
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
|
||||
// qslDefaultMessage returns the operator's default QSL message (Settings), used
|
||||
// on the card when a QSO has no QSLMSG of its own.
|
||||
func (a *App) qslDefaultMessage() string {
|
||||
if a.settings == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
|
||||
return s
|
||||
}
|
||||
|
||||
// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
|
||||
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
|
||||
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
|
||||
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
v := ""
|
||||
if on {
|
||||
v = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
|
||||
}
|
||||
|
||||
// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
|
||||
// the default QSL message printed on the card when a QSO has none of its own.
|
||||
type QSLEmailTemplates struct {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AutoSend bool `json:"auto_send"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AutoSend bool `json:"auto_send"`
|
||||
DefaultMessage string `json:"default_message"`
|
||||
}
|
||||
|
||||
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
||||
@@ -459,7 +499,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
|
||||
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -470,6 +510,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||
out.Body = b
|
||||
}
|
||||
out.AutoSend = m[keyQSLAutoSend] == "1"
|
||||
out.DefaultMessage = m[keyQSLDefaultMsg]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -488,7 +529,10 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
||||
if t.AutoSend {
|
||||
v = "1"
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
|
||||
if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
|
||||
}
|
||||
|
||||
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
|
||||
@@ -627,6 +671,18 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
||||
}
|
||||
return strconv.Itoa(z)
|
||||
}
|
||||
// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
|
||||
// operator's default message (Settings) is used instead.
|
||||
msg := q.QSLMsg
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
msg = a.qslDefaultMessage()
|
||||
}
|
||||
// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
|
||||
// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
|
||||
pseTnx := "PSE QSL"
|
||||
if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
|
||||
pseTnx = "TNX QSL"
|
||||
}
|
||||
vars := map[string]string{
|
||||
"profile.callsign": info.Callsign,
|
||||
"profile.operator_name": info.Operator,
|
||||
@@ -643,7 +699,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
||||
"qso.mode": q.Mode,
|
||||
"qso.submode": q.Submode,
|
||||
"qso.rst_sent": q.RSTSent,
|
||||
"qso.qsl_msg": q.QSLMsg,
|
||||
"qso.qsl_msg": msg,
|
||||
"qso.pse_tnx": pseTnx,
|
||||
"qso.name": q.Name,
|
||||
}
|
||||
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||
|
||||
+14
-8
@@ -18,14 +18,20 @@ const (
|
||||
// sensitiveSettingKeys are the password fields encrypted at rest when the user
|
||||
// sets a passphrase. Everything else stays plaintext.
|
||||
var sensitiveSettingKeys = map[string]bool{
|
||||
keyQRZPassword: true,
|
||||
keyHQPassword: true,
|
||||
keyEmailPassword: true,
|
||||
keyExtClublogPassword: true,
|
||||
keyExtLoTWKeyPassword: true,
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
keyQRZPassword: true,
|
||||
keyHQPassword: true,
|
||||
keyEmailPassword: true,
|
||||
keyExtClublogPassword: true,
|
||||
keyExtLoTWKeyPassword: true,
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
keyExtCloudlogAPIKey: true,
|
||||
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||
// it, so the whole blob is encrypted. That costs nothing — it is read and
|
||||
// written as a unit anyway — and beats storing a server password in clear
|
||||
// next to the rest.
|
||||
keyWebPublish: 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,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
|
||||
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
|
||||
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
|
||||
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
|
||||
// logged, not counted in any award slot, and exports without a BAND field.
|
||||
//
|
||||
// One frequency per band, taken inside the range, plus the edges that used to be
|
||||
// missing entirely.
|
||||
func TestBandPlansAgree(t *testing.T) {
|
||||
cases := []struct {
|
||||
hz int64
|
||||
want string
|
||||
}{
|
||||
{137000, "2190m"},
|
||||
{475000, "630m"},
|
||||
{1840000, "160m"},
|
||||
{3650000, "80m"},
|
||||
{7100000, "40m"},
|
||||
{10120000, "30m"},
|
||||
{14200000, "20m"},
|
||||
{18100000, "17m"},
|
||||
{21200000, "15m"},
|
||||
{24940000, "12m"},
|
||||
{28400000, "10m"},
|
||||
{50150000, "6m"},
|
||||
{70200000, "4m"},
|
||||
{144300000, "2m"},
|
||||
{223500000, "1.25m"},
|
||||
{432000000, "70cm"},
|
||||
{1296000000, "23cm"},
|
||||
// The microwave bands that were missing. 3 cm is the one an operator
|
||||
// reported: 10 GHz is worked and spotted, and resolved to nothing.
|
||||
{2320000000, "13cm"},
|
||||
{3400000000, "9cm"},
|
||||
{5760000000, "6cm"},
|
||||
{10368000000, "3cm"},
|
||||
{24048000000, "1.25cm"},
|
||||
{47088000000, "6mm"},
|
||||
{76032000000, "4mm"},
|
||||
{122250000000, "2.5mm"},
|
||||
{134928000000, "2mm"},
|
||||
{241920000000, "1mm"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cat.BandFromHz(c.hz); got != c.want {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
|
||||
}
|
||||
if got := bandForHz(c.hz); got != c.want {
|
||||
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A frequency in no amateur band must resolve to "" everywhere, not to the
|
||||
// nearest band: guessing here would put a QSO in a band the operator never used.
|
||||
func TestBandPlanRejectsOutOfBand(t *testing.T) {
|
||||
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
|
||||
if got := cat.BandFromHz(hz); got != "" {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
if got := bandForHz(hz); got != "" {
|
||||
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18902
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
|
||||
//
|
||||
// These two lists live in different packages — a TypeScript field table on one
|
||||
// side, a Go whitelist on the other — and they drifted apart the day the
|
||||
// confirmation dates were added: the new columns went into the QSL Manager's
|
||||
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
|
||||
// could not write and the operator got "field … is not bulk-editable" only after
|
||||
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
|
||||
//
|
||||
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
|
||||
// field added to the UI alone fails here immediately.
|
||||
func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
id := m[1]
|
||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||
// band together) and the extras live in extras_json.
|
||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||
continue
|
||||
}
|
||||
col, ok := bulkFieldColumns[id]
|
||||
if !ok {
|
||||
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
|
||||
continue
|
||||
}
|
||||
// Extras live outside the qso table (owner_callsign and friends): they are
|
||||
// written into extras_json, not a column, so the column whitelist does not
|
||||
// apply to them.
|
||||
if strings.HasPrefix(col, "extras.") {
|
||||
continue
|
||||
}
|
||||
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
|
||||
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same contract for the filter builder: every field it offers must be one the
|
||||
// query layer will accept.
|
||||
func TestFilterFieldsAreQueryable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
|
||||
// not, and without it every operator (eq, contains, …) was read as a field.
|
||||
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
col := m[1]
|
||||
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
|
||||
t.Errorf("filter builder offers %q, which the query layer refuses", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+1016
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which class of power a mode falls into.
|
||||
//
|
||||
// This decides how much power leaves the amplifier, so an error here is not a
|
||||
// display bug: a digital mode sorted as phone gets the SSB setting, which is
|
||||
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
|
||||
func TestFlexPowerClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
|
||||
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
|
||||
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
|
||||
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
|
||||
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
|
||||
// Unknown or empty: no class, and the caller changes nothing. Setting a
|
||||
// transmit power from a name we do not recognise is not a good guess.
|
||||
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
|
||||
} {
|
||||
if got := flexPowerClass(c.mode); got != c.want {
|
||||
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3202
-470
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
{!def && ''}
|
||||
</span>
|
||||
)}
|
||||
{!def && <span className="block text-[10px] text-amber-600 leading-tight">{t('adx.nonStandard')}</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"
|
||||
|
||||
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [draft, setDraft] = useState<Rule | null>(null);
|
||||
// Raw text of the callsigns box, kept separately from draft.calls. The list is
|
||||
// normalised (trim + drop empties) on the way into the rule, so binding the
|
||||
// textarea straight to calls.join('\n') round-trips every keystroke through
|
||||
// that normalisation — a trailing newline or space is stripped before React
|
||||
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
|
||||
// deriving the list from it keeps typing intact.
|
||||
const [callsText, setCallsText] = useState('');
|
||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||
const [emailTo, setEmailTo] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
}, []);
|
||||
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||
|
||||
// loadDraft opens a rule in the editor — always through here, so the raw
|
||||
// callsigns text is re-seeded from whatever rule is now being edited.
|
||||
const loadDraft = (r: Rule | null) => {
|
||||
setDraft(r);
|
||||
setCallsText((r?.calls ?? []).join('\n'));
|
||||
};
|
||||
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;
|
||||
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
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(''); }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(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 (!draft.id) { loadDraft(null); return; }
|
||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -116,15 +129,15 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<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>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(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'); }}
|
||||
<button key={r.id} onClick={() => { loadDraft(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-emerald-500' : 'bg-muted-foreground/40')} />
|
||||
<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" />}
|
||||
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<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) })} />
|
||||
value={callsText}
|
||||
onChange={(e) => {
|
||||
setCallsText(e.target.value);
|
||||
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>
|
||||
@@ -240,9 +256,9 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
|
||||
{/* Editor actions */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
|
||||
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Flame, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
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.
|
||||
|
||||
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<button type="button" onClick={toggle}
|
||||
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||
</button>
|
||||
{open && <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).
|
||||
// One second: the same window as the docked widget, so the two never show
|
||||
// different figures for the same amplifier, and neither trails the end of a
|
||||
// transmission long enough to look like lag.
|
||||
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 > 1000) { 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} ckey="amplifier" 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>
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR 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={!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} ckey="amplifier" 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} ckey="amplifier" 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>
|
||||
{(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;
|
||||
// Power comes from the radio's meter stream and nothing else. The
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||
// over: it is a latched maximum that is never reset, and it survives in
|
||||
// the last-known status after the amp disconnects — so it claimed
|
||||
// 1350 W from an old transmission while the radio was putting out 10.
|
||||
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} 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} 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,288 @@
|
||||
import { useRef } from 'react';
|
||||
import { Flame, X, Power } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
|
||||
// (which is the full-size card shown in the FlexRadio panel and Station
|
||||
// Control). It covers all three families, since an operator running an SPE and
|
||||
// a PowerGenius wants one widget, not two:
|
||||
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
|
||||
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
|
||||
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
|
||||
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||
// amp id) chosen from the toolbar icon's dropdown.
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
|
||||
// Full-scale watts for the output bar, from the model string.
|
||||
function maxW(model?: string, fallback = 1300): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
if (m.includes('13K')) return 1300;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function swrColour(swr?: number): string {
|
||||
if (!swr || swr <= 0) return 'text-muted-foreground';
|
||||
if (swr <= 1.5) return 'text-success';
|
||||
if (swr <= 2.0) return 'text-warning';
|
||||
return 'text-danger';
|
||||
}
|
||||
|
||||
// PowerMeter — the output-power LED bar that closes each column, straight under
|
||||
// the controls (no filler above it: pushed to the bottom it left a band of dead
|
||||
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
|
||||
// so it reads exactly like the meters on the rig panels.
|
||||
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
|
||||
return (
|
||||
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
|
||||
display={`${Math.round(watts)} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
);
|
||||
}
|
||||
|
||||
// usePeakHold keeps the highest reading seen for a short window, so a reading
|
||||
// sampled between two syllables doesn't collapse the meter. `live` is the key
|
||||
// part: pass false the moment the carrier drops and the held value is thrown
|
||||
// away at once — holding it there made releasing the PTT look like a five
|
||||
// second lag, which read as the whole app being slow.
|
||||
function usePeakHold(windowMs = 1000) {
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
return (key: string, val: number, live = true) => {
|
||||
if (!live) { delete peak.current[key]; return val; }
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
}
|
||||
|
||||
// Stat — one small labelled figure (power, SWR, temperature).
|
||||
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
|
||||
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
|
||||
// three families (only where the state comes from differs).
|
||||
function OperateButton({ operate, disabled, onClick, t }: {
|
||||
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" disabled={disabled} onClick={onClick}
|
||||
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
|
||||
operate
|
||||
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
|
||||
<Power className="size-3.5" />
|
||||
{operate ? t('ampw.operate') : t('ampw.standby')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AmpBlock — one amplifier inside the widget.
|
||||
function AmpBlock({ amp, flex, showName, t }: {
|
||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const spe = amp.spe, acom = amp.acom;
|
||||
const hold = usePeakHold();
|
||||
|
||||
if (spe || acom) {
|
||||
const s = spe || acom;
|
||||
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||
// gets there first (the amp is polled on its own slower cycle).
|
||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||
// Power ON drives the remote-on control lines, so it stays available while
|
||||
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
|
||||
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" disabled={!canPowerOn}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
|
||||
{t('ampw.on')}
|
||||
</button>
|
||||
<button type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
|
||||
{t('ampw.off')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
|
||||
{spe && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['L', 'M', 'H'] as const).map((lvl) => {
|
||||
const active = (s.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
|
||||
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{t(`ampw.lvl${lvl}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{s.connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
|
||||
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
|
||||
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
|
||||
// reports the amp; the fan mode rides on our own 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;
|
||||
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
|
||||
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const fwd = meter(/^fwd|pwr/i);
|
||||
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
|
||||
// Whether there is power is the RADIO's transmit flag: it flips the instant
|
||||
// the PTT does, where the amplifier's own status poll answers a second and a
|
||||
// half late and holds TRANSMIT past the carrier.
|
||||
//
|
||||
// How MUCH is the live reading — the radio's meter stream, or the amp's own
|
||||
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
|
||||
// latched maximum which is never reset AND survives in the last-known status
|
||||
// after the amp disconnects, so it reported 1350 W from an old transmission
|
||||
// while the radio was putting out 10 W into a disconnected amplifier.
|
||||
const txing = typeof flex?.transmitting === 'boolean'
|
||||
? flex.transmitting
|
||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||
// link only when the radio isn't streaming its meters (a remote link without
|
||||
// the VITA path), so the reading survives but is naturally slower.
|
||||
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||
const id = meter(/^ID$|current/i);
|
||||
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||
const temp = meter(/temp/i);
|
||||
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={operate} disabled={!connected} t={t}
|
||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
|
||||
{connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
|
||||
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
|
||||
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(pg.host || pg.connected) && (
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||
title={t('ampw.fan')}
|
||||
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold 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>
|
||||
)}
|
||||
{fault && fault !== 'NONE' && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{t('flxp.fault')}: {fault}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmpWidget({ amps, flex, sel, onClose }: {
|
||||
amps: Amp[];
|
||||
flex: any;
|
||||
sel: string; // "all" or an amp id
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
// An id that no longer matches (the amp was removed in Settings) falls back to
|
||||
// showing everything rather than an empty widget.
|
||||
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
|
||||
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
|
||||
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
|
||||
|
||||
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">
|
||||
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
|
||||
made a widget taller than the dock row and both ended up half hidden. */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
|
||||
{shown.length === 0 ? (
|
||||
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
|
||||
) : (
|
||||
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
|
||||
{shown.map((a, i) => (
|
||||
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
|
||||
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
import { Antenna, X } from 'lucide-react';
|
||||
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 };
|
||||
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 {
|
||||
@@ -17,49 +33,79 @@ function pretty(name: string): string {
|
||||
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 }: {
|
||||
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 list = status.antennas ?? [];
|
||||
const allAntennas = status.antennas ?? [];
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
// 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-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||
<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-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
|
||||
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||
<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" />
|
||||
@@ -70,7 +116,7 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||
{!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-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</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>
|
||||
@@ -88,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||
: '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} />
|
||||
<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} />
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||
</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';
|
||||
@@ -18,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
|
||||
@@ -30,12 +32,16 @@ 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;
|
||||
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[];
|
||||
ref_display?: string; // grid column shows: ref | name | both
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; prefix?: 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;
|
||||
// The "custom" confirmation source: the field it reads and, optionally, the
|
||||
// value(s) that count. Empty value = any non-empty value confirms.
|
||||
confirm_field?: string; confirm_value?: string;
|
||||
total: number; builtin?: boolean; version?: number;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
@@ -60,7 +66,7 @@ const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||
|
||||
@@ -153,6 +159,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
|
||||
@@ -176,7 +185,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[]; ambiguous?: 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[]) ?? []);
|
||||
@@ -214,17 +307,52 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
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(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||
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) {
|
||||
@@ -245,14 +373,14 @@ 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>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
{/* Left: award list */}
|
||||
<div className="border-r flex flex-col min-h-0">
|
||||
<div className="border-r flex flex-col min-w-0 min-h-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
@@ -260,33 +388,93 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filtered.map(({ d, i }) => (
|
||||
<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="font-mono font-semibold shrink-0">{d.code}</span>
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.map(({ d, i }) => {
|
||||
// An award that is NOT in the shipped catalog exists only in THIS
|
||||
// database: nobody else has it, and a reinstall loses it unless it
|
||||
// has been exported. That deserves to be visible at a glance, not
|
||||
// discovered the hard way.
|
||||
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||
// A pending update is only reachable from the award's own banner, so
|
||||
// the list has to say which award to open — otherwise the fix waits
|
||||
// behind a click nobody knows to make.
|
||||
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||
return (
|
||||
<button key={i} onClick={() => setSel(i)}
|
||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
{hasUpdate && (
|
||||
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
|
||||
<ArrowUpCircle className="size-3.5 text-info" />
|
||||
</span>
|
||||
)}
|
||||
{onlyHere && (
|
||||
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||
title={t('awed.onlyHereTip')}>
|
||||
{t('awed.onlyHere')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Right: tabbed editor for selected award.
|
||||
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
|
||||
track has min-width:auto — it refuses to shrink below its content's
|
||||
intrinsic width, so a wide row (the band/mode chip lists, a long
|
||||
translated label) grew the track and pushed the whole editor out
|
||||
past the dialog instead of scrolling inside it. Same reason on the
|
||||
Tabs below: a flex item defaults to min-width:auto too. */}
|
||||
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||
apply it — that would destroy their work — so we offer it, and say
|
||||
plainly what accepting costs. */}
|
||||
{cur && selUpdate && (
|
||||
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="size-4 text-info shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{t('awed.updateAvailable')}</div>
|
||||
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
|
||||
</div>
|
||||
<Button size="sm" className="h-7 px-2 text-[11px]"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await ApplyAwardUpdate(cur.code);
|
||||
// The backend rewrote this award (and its references) — pull the
|
||||
// new state back, or the editor would keep showing, and on the
|
||||
// next Save re-persist, the definition we just replaced.
|
||||
setDefs(((await GetAwardDefs()) ?? []) as any);
|
||||
loadMeta();
|
||||
loadUpdates();
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}}>{t('awed.updateApply')}</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
|
||||
onClick={async () => {
|
||||
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}}>{t('awed.updateKeepMine')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
@@ -296,11 +484,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||
the catalog derives that on load. Asking the author to tick a
|
||||
box to declare it would be one more step nobody can guess —
|
||||
forget it and the award silently misses every future catalog
|
||||
correction. "Protected" stays: whether an award can be deleted
|
||||
IS a real choice. */}
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||
</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<Field2 label={t('awed.refDisplay')}>
|
||||
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
|
||||
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
|
||||
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
@@ -327,7 +534,9 @@ 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 })} /> {t('awed.allowMultiple')}</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">
|
||||
@@ -348,14 +557,18 @@ 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 })} /> {t('awed.exactMatch')}</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</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">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></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">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||
@@ -412,8 +625,136 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field2 label={t('awed.grantCodes')}><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 })} /> {t('awed.exportCreditGranted')}</label>
|
||||
|
||||
{/* "Custom" is only meaningful once it names a field, so the
|
||||
inputs appear as soon as it is ticked in either column —
|
||||
and a custom source with no field confirms nothing. */}
|
||||
{((cur.confirm ?? []).includes('custom') || (cur.validate ?? []).includes('custom')) && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('awed.customLabel')}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] text-muted-foreground">{t('awed.customField')}</Label>
|
||||
<Input className="h-8 font-mono text-xs" placeholder="APP_OPSLOG_QSL_RCVD"
|
||||
value={(cur as any).confirm_field ?? ''}
|
||||
onChange={(e) => patch({ confirm_field: e.target.value.trim().toUpperCase() } as any)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] text-muted-foreground">{t('awed.customValue')}</Label>
|
||||
<Input className="h-8 font-mono text-xs" placeholder="Y,V"
|
||||
value={(cur as any).confirm_value ?? ''}
|
||||
onChange={(e) => patch({ confirm_value: e.target.value } as any)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('awed.customHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||
did nothing at all. The stored values are kept (see award.Def);
|
||||
put the controls back when the export is actually wired up. */}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Test ──
|
||||
Runs the SAVED award against a real QSO and shows every rule:
|
||||
what it scanned, what it produced, and why each candidate was
|
||||
rejected. An award that matches nothing used to be a black box. */}
|
||||
<TabsContent value="test" className="mt-0 space-y-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||
{t('awed.testRun')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||
</div>
|
||||
|
||||
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||
|
||||
{testGroups?.map((g, ri) => {
|
||||
const row = g.rows[0];
|
||||
const ex = row.explanation;
|
||||
const matched = (ex.result ?? []).length > 0;
|
||||
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
|
||||
return (
|
||||
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||
{g.rows.length > 1 && (
|
||||
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!ex.in_scope ? (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{(ex.steps ?? []).map((s, si) => (
|
||||
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||
</span>
|
||||
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||
</div>
|
||||
{!s.skipped && !s.error && (
|
||||
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||
{s.field_value
|
||||
? <span className="font-mono">{s.field_value}</span>
|
||||
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||
</div>
|
||||
{(s.kept ?? []).map((k) => (
|
||||
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||
))}
|
||||
{(s.rejected ?? []).map((r, i2) => (
|
||||
<div key={i2} className="font-mono text-muted-foreground">
|
||||
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(ex.ambiguous ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
|
||||
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
|
||||
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
|
||||
</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 ── */}
|
||||
@@ -430,7 +771,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
{/* Eight buttons on one unwrappable row fitted in English and overflowed
|
||||
in French, where the labels are half again as long. Wrap instead of
|
||||
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
|
||||
margin-left approach leaves no vertical gap once a row wraps. */}
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
|
||||
<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')}
|
||||
@@ -438,11 +783,90 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<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}>{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>
|
||||
);
|
||||
}
|
||||
@@ -567,7 +991,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
<div className="flex-1 overflow-auto">
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
|
||||
{!busy && large && q.trim().length < 2 && (
|
||||
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
<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>
|
||||
)}
|
||||
@@ -595,26 +1019,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
</div>
|
||||
{/* One per row: side by side, each half spends 120px of its width on
|
||||
the label column and the input is left too narrow to read a group
|
||||
name ("Basilicata" → "Basili"). */}
|
||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||
<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">{t('awed.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">{t('awed.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">{t('awed.grid')}</Label>
|
||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
@@ -50,9 +50,9 @@ 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={t('awrp.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
|
||||
|
||||
@@ -216,19 +216,15 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
<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">
|
||||
{/* Selected ref chip (nothing shown until one is picked) */}
|
||||
{selectedRef && (
|
||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||
<span className="truncate text-[10px] text-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">
|
||||
{t('awrs.pickReference')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add — references are always scoped to the contacted DXCC */}
|
||||
@@ -284,15 +280,15 @@ 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={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
||||
>
|
||||
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||
<span className="text-emerald-700"> {t('awrs.fromField', { field: selField })}</span>
|
||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">{t('awrs.autoClickToAdd')}</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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
|
||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -8,12 +9,15 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
ref: string; name?: string; group?: string; subgrp?: string;
|
||||
worked: boolean; confirmed: boolean; validated: boolean;
|
||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||
modes?: string[]; confirmed_modes?: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
@@ -36,9 +40,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: '' };
|
||||
@@ -53,19 +57,22 @@ 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).
|
||||
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
|
||||
// are different results, and caching them under one key showed the previous
|
||||
// mode’s numbers after switching.
|
||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -74,6 +81,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||
// confirmed" is two questions at once, and answering only one of them is what
|
||||
// sends an operator to a spreadsheet.
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
|
||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||
const [showMissing, setShowMissing] = useState(false);
|
||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||
@@ -112,13 +123,14 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||
async function compute(code: string, force = false) {
|
||||
if (!code) return;
|
||||
if (!force && byCode[code]) { setSelected(code); return; }
|
||||
const key = `${code}|${modeFilter}`;
|
||||
if (!force && byCode[key]) { setSelected(code); return; }
|
||||
setSelected(code);
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = (await GetAward(code)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [code]: r }));
|
||||
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [key]: r }));
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -126,13 +138,20 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
}
|
||||
}
|
||||
|
||||
// Load the award list (no QSO scan), then compute only the first award.
|
||||
// Load the award list (no QSO scan), then compute only the first award. The
|
||||
// list is narrowed to the awards the operator follows (Settings → Awards); an
|
||||
// empty follow-set means "show them all" so the tab is never blank.
|
||||
async function loadList() {
|
||||
try {
|
||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||
const list: AwardListItem[] = defs
|
||||
const [defs, tracked] = await Promise.all([
|
||||
GetAwardDefs().then((d) => (d ?? []) as any[]),
|
||||
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
|
||||
]);
|
||||
const follow = new Set(tracked);
|
||||
let list: AwardListItem[] = defs
|
||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||
setAwardList(list);
|
||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||
if (first) compute(first.code);
|
||||
@@ -141,8 +160,17 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
}
|
||||
}
|
||||
useEffect(() => { loadList(); }, []);
|
||||
// Re-filter when the operator changes their followed awards in Settings.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
|
||||
return () => { off(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const current = byCode[selected];
|
||||
const current = byCode[`${selected}|${modeFilter}`];
|
||||
// Recompute when the mode class changes: the bands, counts and confirmations
|
||||
// all describe the selected mode, so they cannot be filtered client-side.
|
||||
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||
|
||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||
@@ -191,17 +219,81 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
return idx.length ? idx : all;
|
||||
}, [stats, awardBands]);
|
||||
|
||||
// Sort order for the reference table. Reference is the default because that
|
||||
// is how award lists are published and how an operator reads a paper list;
|
||||
// description answers the other question — "have I worked anything in
|
||||
// Alicante" — which reference order scatters across the whole table.
|
||||
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||
// travels with a copied data/ folder like every other UI preference here.
|
||||
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
|
||||
() => {
|
||||
const v = localStorage.getItem('opslog.awardRefSort');
|
||||
return v === 'name' || v === 'group' ? v : 'ref';
|
||||
});
|
||||
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
|
||||
if (k === refSort) {
|
||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||
setRefSortDir(d);
|
||||
writeUiPref('opslog.awardRefSortDir', d);
|
||||
} else {
|
||||
setRefSort(k);
|
||||
setRefSortDir('asc');
|
||||
writeUiPref('opslog.awardRefSort', k);
|
||||
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
return (current.refs ?? []).filter((r) => {
|
||||
const rows = (current.refs ?? []).filter((r) => {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||
// a contradiction: the mode filter stands aside rather than emptying
|
||||
// the list.
|
||||
if (!(r.modes ?? []).includes(modeFilter)) return false;
|
||||
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
|
||||
// on CW and confirmed on SSB is still a CW entity to chase, and the
|
||||
// whole point of the filter is to find those.
|
||||
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
|
||||
}
|
||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [current, refSearch, refFilter]);
|
||||
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||
return rows.sort((a, b) => {
|
||||
if (refSort === 'name') {
|
||||
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||
// than after "Zaragoza".
|
||||
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||
}
|
||||
if (refSort === 'group') {
|
||||
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
|
||||
// entities. Blank prefixes sink to the bottom regardless of direction,
|
||||
// so an unresolved entity never heads the list.
|
||||
const ag = a.group ?? '', bg = b.group ?? '';
|
||||
if (!ag !== !bg) return ag ? -1 : 1;
|
||||
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
|
||||
// The group column earns its width only when the list actually carries one
|
||||
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||
// value IS the primary prefix, so it is labelled as such.
|
||||
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
|
||||
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
@@ -220,7 +312,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{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">
|
||||
@@ -240,7 +332,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||
{awardList.map((a) => {
|
||||
const r = byCode[a.code];
|
||||
const r = byCode[`${a.code}|${modeFilter}`];
|
||||
return (
|
||||
<button
|
||||
key={a.code}
|
||||
@@ -255,7 +347,7 @@ 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"> {t('awp.of')} {r.total}</span>}
|
||||
</span>
|
||||
@@ -286,8 +378,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
|
||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||
<span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
||||
<span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||
{current.total > 0 && (
|
||||
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
|
||||
)}
|
||||
@@ -303,7 +395,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{(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>
|
||||
))}
|
||||
@@ -325,10 +417,18 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setModeFilter(k)}
|
||||
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||
title={t('awp.missingRefsTitle')}
|
||||
>
|
||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||
@@ -336,9 +436,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<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={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||
@@ -370,7 +470,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>
|
||||
@@ -386,8 +486,26 @@ 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">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.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">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
{hasGroup && (
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
|
||||
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{groupColLabel}
|
||||
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.description')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
@@ -397,9 +515,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{filteredRefs.map((r) => (
|
||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||
</td>
|
||||
{hasGroup && (
|
||||
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
|
||||
)}
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
|
||||
{gridBands.map((b) => {
|
||||
const s = cellStatus(r, b);
|
||||
return (
|
||||
@@ -424,9 +543,24 @@ 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">{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">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.name')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">
|
||||
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{groupColLabel}
|
||||
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</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>
|
||||
@@ -439,13 +573,13 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
||||
: r.validated ? <span className="text-emerald-600">{t('awp.validated')}</span>
|
||||
: r.confirmed ? <span className="text-amber-600">{t('awp.confirmed')}</span>
|
||||
: <span className="text-stone-500">{t('awp.worked')}</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>
|
||||
@@ -459,7 +593,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
</div>
|
||||
|
||||
{cell && current && (
|
||||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||||
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
|
||||
)}
|
||||
{showMissing && current && (
|
||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||
@@ -558,7 +692,7 @@ 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" />
|
||||
<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" />
|
||||
@@ -589,7 +723,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
||||
</Button>
|
||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
||||
{msg && <span className="text-[11px] text-success">{msg}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
@@ -644,17 +778,17 @@ 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 }) {
|
||||
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
AwardCellQSOs(code, cell.ref, cell.band)
|
||||
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
|
||||
.then((r) => setQsos((r ?? []) as any))
|
||||
.catch(() => setQsos([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code, cell.ref, cell.band]);
|
||||
}, [code, cell.ref, cell.band, modeClass]);
|
||||
|
||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||
|
||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||
@@ -24,7 +25,40 @@ interface Spot {
|
||||
spotter?: string;
|
||||
}
|
||||
|
||||
type SpotStatusEntry = { status: string; country?: string };
|
||||
// The FULL status entry, not the two fields the map used to declare. The extra
|
||||
// markers were already arriving in this object — the local type simply never
|
||||
// mentioned them, so they could not be drawn.
|
||||
type SpotStatusEntry = {
|
||||
status?: string;
|
||||
country?: string;
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
};
|
||||
|
||||
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||
// entity AND a new park. The cluster list stacks them as separate badges instead
|
||||
// of letting one replace another, so the map stacks them too — as segments of
|
||||
// the pill's left accent bar, which until now only repeated the pill's own
|
||||
// colour and carried no information of its own.
|
||||
//
|
||||
// Their colours come from lib/spotMarkers, shared with the cluster list: the
|
||||
// same fact must not be violet in one panel and green in the next.
|
||||
// The map shows three of the four. A new PREFIX is left to the cluster list: the
|
||||
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
|
||||
// nobody can read at a glance. Add it here the day the strip earns more room.
|
||||
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx');
|
||||
const markersFor = (e: SpotStatusEntry | undefined) =>
|
||||
activeMarkers(e).filter((m) => m.key !== 'new_pfx');
|
||||
|
||||
// The legend spells the markers out; the cluster list's badges are abbreviated
|
||||
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
|
||||
const BMP_MARKER_LABEL: Record<string, string> = {
|
||||
new_pota: 'bmp.legendNewPota',
|
||||
new_county: 'bmp.legendNewCounty',
|
||||
worked_call: 'bmp.legendWorkedCall',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
band: string;
|
||||
@@ -35,6 +69,16 @@ 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;
|
||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||
keyNav?: boolean;
|
||||
}
|
||||
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
@@ -54,25 +98,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
||||
'70cm': [430000, 440000],
|
||||
};
|
||||
|
||||
// Sub-band shading: CW / digital / phone.
|
||||
//
|
||||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
||||
// categorical hues — never the status tokens. They used to use success / info /
|
||||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
||||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
||||
// a giant "new band" wash. Status colours are reserved for status.
|
||||
//
|
||||
// All three are drawn from the COOL end of the categorical order and laid down at
|
||||
// low opacity, so the band plan stays background context while the warm spot pills
|
||||
// keep the foreground to themselves.
|
||||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
||||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
||||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
||||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
||||
const SEG_CW = 'var(--chart-7)'; // magenta
|
||||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
||||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
||||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||
// are read on top of it.
|
||||
const SEG_OPACITY = 0.13;
|
||||
|
||||
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, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||
'60m': [[5350, 5450, SEG_PHONE]],
|
||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn('size-2 rounded-full', cls)} />
|
||||
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -97,22 +163,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',
|
||||
@@ -147,7 +213,7 @@ 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, keyNav = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
@@ -167,7 +233,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
|
||||
@@ -199,7 +270,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
|
||||
@@ -272,12 +344,16 @@ 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.
|
||||
@@ -288,15 +364,19 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
// 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 (!range || containerH <= 0) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
// Fit-to-band: keep the view at the top so the full band is always in frame;
|
||||
// any overflow spots are reached by scrolling down (don't follow the rig).
|
||||
if (fitToBand) { el.scrollTop = 0; return; }
|
||||
if (currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz]);
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
@@ -312,6 +392,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [range]);
|
||||
|
||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||||
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
|
||||
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
|
||||
// hook, so the hook count changed between renders and React crashed with #300
|
||||
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
|
||||
// back to [0,1] when there's no range), so it's safe to run here.
|
||||
useEffect(() => {
|
||||
if (!keyNav) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
const tag = (ae?.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||||
const list = spots
|
||||
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||||
.slice()
|
||||
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||||
if (!list.length) return;
|
||||
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||||
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||||
let target: Spot | undefined;
|
||||
if (e.key === 'ArrowUp') {
|
||||
target = list.find((s) => s.freq_hz > cur + EPS);
|
||||
} else {
|
||||
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||||
}
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
onSpotClick(target);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||
|
||||
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">
|
||||
@@ -350,13 +467,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||
<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={zoomIdx === 0}
|
||||
<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="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{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}
|
||||
<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" />
|
||||
@@ -391,10 +508,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
height={totalH}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{segments.map(([s, e, cls], i) => {
|
||||
{segments.map(([s, e, colour], i) => {
|
||||
const y1 = freqToY(Math.min(e, hi));
|
||||
const y2 = freqToY(Math.max(s, lo));
|
||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
||||
return (
|
||||
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
|
||||
fill={colour} fillOpacity={SEG_OPACITY} />
|
||||
);
|
||||
})}
|
||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||
</svg>
|
||||
@@ -481,10 +601,21 @@ 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, t)}${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)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${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 />
|
||||
{/* Left accent strip. With no extra marker it repeats the status
|
||||
colour, exactly as before; otherwise it splits into one
|
||||
segment per marker, so "worked entity + new park" shows both
|
||||
instead of one hiding the other. */}
|
||||
{(() => {
|
||||
const marks = markersFor(entry);
|
||||
if (marks.length === 0) return <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />;
|
||||
return (
|
||||
<span className="w-1 shrink-0 flex flex-col" aria-hidden>
|
||||
{marks.map((m) => <span key={m.key} className="flex-1" style={{ background: m.colour }} />)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||
<span>{p.spot.dx_call}</span>
|
||||
{mode && (
|
||||
@@ -500,14 +631,24 @@ 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={t('bmp.legendNewDxcc')} />
|
||||
<LegendDot cls="bg-amber-400" label={t('bmp.legendNewBand')} />
|
||||
<LegendDot cls="bg-yellow-300" label={t('bmp.legendNewSlot')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||
<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")} />
|
||||
{/* The stacked markers, straight from the shared table so the legend
|
||||
cannot drift from what the pills actually draw. */}
|
||||
{BMP_MARKERS.map((m) => (
|
||||
<LegendDot key={m.key} colour={m.colour} label={t(BMP_MARKER_LABEL[m.key])} />
|
||||
))}
|
||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||
<span className="mx-0.5 opacity-40">|</span>
|
||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||||
</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">
|
||||
{t('bmp.footerHint')}
|
||||
{hidden > 0 && <span className="text-amber-600"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Star, Radio } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sunTimes } from '@/lib/sun';
|
||||
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -13,6 +15,16 @@ interface Props {
|
||||
currentMode: string;
|
||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||
// to an entity with no position at all, and the block simply does not appear.
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
// Set when the matrix is showing a QSO picked in the log grid rather than the
|
||||
// entry form — labelled so the two can never be confused.
|
||||
forCall?: string;
|
||||
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
|
||||
// clickable. Threaded down from App.openEdit.
|
||||
onEditQso?: (id: number) => void;
|
||||
}
|
||||
|
||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||
@@ -51,22 +63,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 {
|
||||
@@ -79,7 +96,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||
}
|
||||
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||
// Cell drill-down: which band+class the operator clicked, or null.
|
||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||
// Columns from the operator's configured bands (so the matrix shows only the
|
||||
// bands they actually use), falling back to the built-in default set.
|
||||
const cols = useMemo(
|
||||
@@ -89,6 +108,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 +124,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 +151,84 @@ 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;
|
||||
|
||||
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
|
||||
// about to open or close on their side, which is the reason to look at a DX
|
||||
// station's grey line at all. Recomputed only when the position changes.
|
||||
const sun = useMemo(
|
||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||
[lat, lon],
|
||||
);
|
||||
const sunBlock = sun ? (
|
||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||
title="Sunrise / sunset at the DX station (UTC)">
|
||||
{sun.polarDay ? (
|
||||
<span className="font-semibold text-warning">midnight sun</span>
|
||||
) : sun.polarNight ? (
|
||||
<span className="font-semibold text-info">polar night</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunrise className="size-3.5 text-warning" />
|
||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunset className="size-3.5 text-info" />
|
||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">UTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : 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]">
|
||||
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||
{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 ? (
|
||||
<>
|
||||
{/* Says WHOSE stats these are when they come from a row picked in
|
||||
the log rather than from what's being typed. */}
|
||||
{forCall && (
|
||||
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
|
||||
title="Stats for the QSO selected in the log">
|
||||
{forCall}
|
||||
</Badge>
|
||||
)}
|
||||
<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 +242,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>
|
||||
)}
|
||||
</>
|
||||
@@ -178,6 +259,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
Type a callsign to see entity stats
|
||||
</span>
|
||||
)}
|
||||
{sunBlock}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -217,11 +299,15 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return (
|
||||
<td
|
||||
key={b.tag}
|
||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
||||
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||
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',
|
||||
// Only a filled cell has anything to show — an empty one
|
||||
// stays inert rather than opening a "no QSOs" dialog.
|
||||
st && 'cursor-pointer hover:brightness-110',
|
||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -240,7 +326,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}
|
||||
@@ -248,6 +334,119 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slot && (
|
||||
<SlotQSOModal
|
||||
call={wb?.callsign ?? ''}
|
||||
dxcc={dxcc}
|
||||
entity={dxccName}
|
||||
band={slot.band}
|
||||
cls={slot.cls}
|
||||
onClose={() => setSlot(null)}
|
||||
onEdit={onEditQso}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cell drill-down ──────────────────────────────────────────────────────────
|
||||
// The contacts behind one band+class cell: this exact callsign AND anyone else
|
||||
// in the entity, because that is the pair of facts the cell's colour encodes.
|
||||
// The call's own QSOs are marked so the two never blur together.
|
||||
|
||||
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
|
||||
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<any[] | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
BandSlotQSOs(call, dxcc, band, cls)
|
||||
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
|
||||
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
|
||||
return () => { dead = true; };
|
||||
}, [call, dxcc, band, cls]);
|
||||
|
||||
useEffect(() => {
|
||||
// Capture phase: the app's global ESC handler resets the entry form, and
|
||||
// closing a dialog must not also wipe what the operator was typing.
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4" onClick={onClose}>
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
|
||||
<span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm leading-tight truncate">
|
||||
{cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
|
||||
</div>
|
||||
{onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
|
||||
{!rows ? (
|
||||
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Loading…
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-3 py-1.5">Date UTC</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Callsign</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Mode</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Freq</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Name</th>
|
||||
<th className="text-center font-medium px-3 py-1.5">Cfm</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((q, i) => {
|
||||
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
|
||||
const mine = q.callsign === call;
|
||||
return (
|
||||
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
||||
<td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{onEdit ? (
|
||||
<button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
|
||||
className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
|
||||
{q.callsign}
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">{q.mode}</td>
|
||||
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
|
||||
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success">✓</span> : <span className="text-muted-foreground/30">·</span>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldKind = 'status' | 'text';
|
||||
type FieldKind = 'status' | 'text' | 'freq' | 'date';
|
||||
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.
|
||||
@@ -21,19 +21,36 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
||||
// 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' },
|
||||
// One channel at a time, each with its status and its date, in the same order
|
||||
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||
// status" or "… date", which is also what the ADIF field is called.
|
||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
// 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 },
|
||||
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', 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' },
|
||||
@@ -49,7 +66,35 @@ const FIELDS: FieldDef[] = [
|
||||
{ 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: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ 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' },
|
||||
@@ -65,11 +110,14 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
||||
{ v: '_', label: 'bulk.statusBlank' },
|
||||
];
|
||||
|
||||
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -85,7 +133,7 @@ type Props = {
|
||||
// so they become eligible for upload.
|
||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [field, setField] = useState('hrdlog_upload');
|
||||
const [field, setField] = useState('qsl_sent');
|
||||
const [statusValue, setStatusValue] = useState('R');
|
||||
const [textValue, setTextValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -130,9 +178,12 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
{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) => (
|
||||
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem>
|
||||
))}
|
||||
{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>
|
||||
@@ -146,11 +197,35 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : def.kind === 'date' ? (
|
||||
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
|
||||
// writes an empty value — which is how a wrong date is removed
|
||||
// from a batch, the same meaning as leaving a text field blank.
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-xs font-mono"
|
||||
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
|
||||
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
|
||||
/>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
|
||||
title={t('bulk.todayUtc')}
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
// UTC: the whole log is UTC, and near midnight the local day
|
||||
// is the wrong one.
|
||||
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
|
||||
}}
|
||||
>{t('bulk.today')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={textValue}
|
||||
placeholder={t('bulk.clearPlaceholder')}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
@@ -158,9 +233,9 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||
<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-rose-700">{error}</div>}
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -76,32 +76,32 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
<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">{t('chp.dateUtc')}</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">{t('chp.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">{t('chp.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={t('chp.lotwRcvd')}>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={t('chp.bureauRcvd')}>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>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
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-sky-600" />
|
||||
<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. */}
|
||||
@@ -66,7 +66,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
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-sky-100 text-sky-900' : 'bg-muted')}>
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 {
|
||||
@@ -11,34 +12,15 @@ import {
|
||||
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 { markerColour } from '@/lib/spotMarkers';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } 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;
|
||||
@@ -70,6 +52,10 @@ export type SpotStatusEntry = {
|
||||
country?: string;
|
||||
continent?: string;
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
pfx?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -116,38 +102,88 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
];
|
||||
}
|
||||
|
||||
// statusBadge maps a resolved spot status to a short labelled badge for the
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
|
||||
//
|
||||
// The pills were dropped: a rounded box has its own height and padding, so it
|
||||
// sat off the row's baseline and the callsign inside it no longer lined up with
|
||||
// the plain callsigns above and below. A whole column of them read as chrome
|
||||
// rather than as data. Same reasoning — and the same semantic tokens — as the
|
||||
// Y/N/R QSL columns in lib/qslStatus.ts.
|
||||
//
|
||||
// Which cell is coloured IS the message, so one colour is enough for all three:
|
||||
//
|
||||
// yellow call → new DXCC yellow band → new band yellow mode → new mode
|
||||
// blue call → already worked
|
||||
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||
|
||||
// cellText renders a cell value in an optional colour. An empty value keeps the
|
||||
// muted dash the grid used before, so blank cells still read as "nothing here"
|
||||
// rather than as a gap.
|
||||
function cellText(value: any, color: string | null): any {
|
||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||
}
|
||||
|
||||
// statusColor is the colour for a resolved status in the Status column. County,
|
||||
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
|
||||
// DXCC story and an operator filters on them separately.
|
||||
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
case 'new':
|
||||
case 'new-band':
|
||||
case 'new-mode':
|
||||
case 'new-slot':
|
||||
return NEW;
|
||||
default:
|
||||
return s?.worked_call ? WKD : null;
|
||||
}
|
||||
}
|
||||
|
||||
// isDull reports a spot that "represents nothing" under the current worked/slot
|
||||
// rules: the entity/band/mode is already worked (status resolved, not new in any
|
||||
// dimension), the callsign itself isn't in the log, and there's no POTA / county
|
||||
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
|
||||
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
|
||||
// what counts as dull follows those settings automatically. Unresolved statuses
|
||||
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
||||
function isDull(s: SpotStatusEntry | undefined): boolean {
|
||||
if (!s || !s.status) return false;
|
||||
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false;
|
||||
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
|
||||
}
|
||||
|
||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{
|
||||
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: 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 → yellow call. Already worked → blue call. Anything else keeps
|
||||
// the theme's normal ink so ordinary callsigns don't shout.
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
|
||||
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||
@@ -155,26 +191,51 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
||||
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.
|
||||
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||
// the row level, not just a single coloured cell — NEW SLOT in particular
|
||||
// colours no cell at all, since neither the band nor the mode is new on its
|
||||
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
||||
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'));
|
||||
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||
return parts.join(' ');
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(t, statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
const s = statusFor(p);
|
||||
const parts: { text: string; color: string }[] = [];
|
||||
const main = statusColor(s);
|
||||
if (main) {
|
||||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||
: t('clg2.wkdCall');
|
||||
parts.push({ text: label, color: main });
|
||||
}
|
||||
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||
// never one colour here and another there.
|
||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
||||
if (parts.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={{ whiteSpace: 'nowrap' }}>
|
||||
{parts.map((pt, i) => (
|
||||
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
||||
{i > 0 ? ' · ' : ''}{pt.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
@@ -190,7 +251,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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' },
|
||||
cellStyle: { color: 'var(--success)' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||
},
|
||||
{
|
||||
@@ -205,10 +266,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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),
|
||||
// NEW BAND for this entity → the band text turns yellow.
|
||||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
@@ -217,16 +276,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
||||
cellStyle: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
||||
// Status badge text tells them apart.
|
||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
||||
return undefined;
|
||||
},
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
// 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) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
@@ -238,7 +292,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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: t('clg2.c.cqz'), colId: 'cqz',
|
||||
@@ -275,7 +329,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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: t('clg2.c.continent'), colId: 'continent',
|
||||
@@ -284,31 +338,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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: 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: 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: t('clg2.c.locator'), colId: 'locator',
|
||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
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: t('clg2.c.received_at'), colId: 'received_at',
|
||||
@@ -334,13 +388,36 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
// 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]);
|
||||
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||
// matching column events. Without this guard those events were persisted, so a
|
||||
// single language change overwrote the saved cluster layout — in the cache AND
|
||||
// in the database, with nothing to restore from.
|
||||
const restoringRef = useRef(true);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
return COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
});
|
||||
}, [COL_CATALOG]);
|
||||
|
||||
// Re-apply the saved state after every rebuild, then re-enable saving.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(tm);
|
||||
}, [columnDefs]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
// Dim "represents nothing" spots at the CELL level, via a class that
|
||||
// refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
|
||||
// every status update, which re-rendered the whole grid continuously and
|
||||
// pegged the CPU on a busy cluster (the 0.23.6 slowdown).
|
||||
cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
|
||||
}), []);
|
||||
|
||||
// Pass spotStatus through AG Grid's context so cell renderers can look up
|
||||
@@ -350,24 +427,37 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
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.
|
||||
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
|
||||
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
|
||||
// refresh so e.g. a worked call turns blue once its status loads.
|
||||
//
|
||||
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
|
||||
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
|
||||
// into one refresh every 200 ms (still imperceptible) instead of one per update.
|
||||
const refreshPending = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
if (refreshPending.current !== undefined) return; // a refresh is already queued
|
||||
refreshPending.current = window.setTimeout(() => {
|
||||
refreshPending.current = undefined;
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
}, 200);
|
||||
}, [spotStatus]);
|
||||
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
||||
// save it from reading the wrong (unscoped) cache key at first paint.
|
||||
async function onGridReady(e: GridReadyEvent) {
|
||||
await whenGridPrefsReady();
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
const remote = await loadRemote(COL_STATE_KEY);
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}, []);
|
||||
|
||||
@@ -85,9 +85,9 @@ export function ContestPanel({ session, onChange }: {
|
||||
{/* Setup card. */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||
<Trophy className="size-4 text-amber-500" />
|
||||
<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-amber-500 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-white">{t('ctp.live')}</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">
|
||||
@@ -150,13 +150,13 @@ export function ContestPanel({ session, onChange }: {
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{session.active ? (
|
||||
<button type="button" onClick={() => onChange({ active: false })}
|
||||
className="rounded-md border border-rose-500/60 bg-rose-500/10 px-4 py-1.5 text-sm font-bold text-rose-600 hover:bg-rose-500/20">
|
||||
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-amber-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-40">
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -53,6 +53,14 @@ interface Props {
|
||||
prefix: string;
|
||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
||||
// DOK keys off QTH, not the address — and sending only the address meant such
|
||||
// an award stayed silent until the QSO was logged.
|
||||
qth?: string;
|
||||
name?: string;
|
||||
country?: string;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||
@@ -61,6 +69,14 @@ interface Props {
|
||||
band: string;
|
||||
mode: string;
|
||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||
slotCall?: string;
|
||||
slotBand?: string;
|
||||
slotMode?: string;
|
||||
slotWb?: any;
|
||||
slotWbBusy?: boolean;
|
||||
imageUrl?: string;
|
||||
onOpenImage?: () => void;
|
||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||
@@ -69,6 +85,8 @@ interface Props {
|
||||
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||
keyerActive?: boolean;
|
||||
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
|
||||
onEditQso?: (id: number) => void;
|
||||
}
|
||||
|
||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
@@ -113,28 +131,57 @@ function numOrUndef(v: string): number | undefined {
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
|
||||
// for literal class names, so an interpolated one is never emitted.
|
||||
const SPAN_CLASS: Record<number, string> = {
|
||||
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
|
||||
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
|
||||
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||
};
|
||||
|
||||
// Compact field helper to keep the JSX dense.
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
||||
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
|
||||
<Label className="mb-1">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Live award detection: run the SAME engine used at log time over the current
|
||||
// contact (callsign + looked-up address/state/zones) so award references the
|
||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
||||
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
||||
// a DOK matching the town — are surfaced and auto-added the moment you enter a
|
||||
// call or click a spot, instead of only appearing after logging. Pickable
|
||||
// matches are merged into award_refs
|
||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
||||
|
||||
// NEW badge on the county: the same question the cluster's NEW CTY badge
|
||||
// answers, for the station being worked right now. Debounced because the
|
||||
// county field is typed into as well as filled by the lookup.
|
||||
const [newCounty, setNewCounty] = useState(false);
|
||||
useEffect(() => {
|
||||
const cnty = (details.cnty ?? '').trim();
|
||||
if (!cnty) { setNewCounty(false); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try {
|
||||
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||
if (alive) setNewCounty(!!isNew);
|
||||
} catch { if (alive) setNewCounty(false); }
|
||||
}, 300);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [details.state, details.cnty]);
|
||||
useEffect(() => {
|
||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
@@ -142,6 +189,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const q: any = {
|
||||
callsign, band, mode,
|
||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
||||
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||
qso_date: new Date().toISOString(),
|
||||
};
|
||||
@@ -151,7 +200,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const have = new Set(cur.split(';').filter(Boolean));
|
||||
let next = cur;
|
||||
for (const r of all) {
|
||||
if (!r.pickable) continue;
|
||||
// An ambiguous candidate is offered, never auto-added: adding one would
|
||||
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
||||
if (!r.pickable || r.ambiguous) continue;
|
||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||
}
|
||||
@@ -160,7 +211,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(() => {
|
||||
@@ -174,6 +225,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}
|
||||
return null;
|
||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||
// have one — it is what the operator can see and check — and the provider's
|
||||
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||
const dxLL = useMemo(() => {
|
||||
const byGrid = gridToLatLon(remoteGrid);
|
||||
if (byGrid) return byGrid;
|
||||
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||
return null;
|
||||
}, [remoteGrid, details.lat, details.lon]);
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
@@ -222,19 +282,40 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
<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() !== ''} />
|
||||
<BandSlotGrid
|
||||
wb={slotCall ? slotWb : wb}
|
||||
busy={slotCall ? !!slotWbBusy : !!wbBusy}
|
||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||
bands={bands}
|
||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||
forCall={slotCall}
|
||||
onEditQso={onEditQso}
|
||||
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label={t('detp.statePref')}>
|
||||
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||
{/* Twelve columns, not six: the fields hold very different things. A US
|
||||
county name is long, a CQ zone is two digits — on an even six-column
|
||||
grid they were the same width, so the county truncated while the
|
||||
zones sat mostly empty. */}
|
||||
<Field label={t('detp.statePref')} span={2}>
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.county')}>
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
<Field label={t('detp.county')} span={4} className="relative">
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
|
||||
{newCounty && (
|
||||
<span
|
||||
title={t('detp.newCountyTip')}
|
||||
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||
>
|
||||
{t('detp.newCounty')}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t('detp.prefix')}>
|
||||
<Field label={t('detp.prefix')} span={2}>
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
<Field label={t('detp.cqZone')}>
|
||||
@@ -248,7 +329,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Field label={t('detp.dxcc')} span={2}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -257,40 +338,45 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceShort) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.address')} span={3}>
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslMessage')} span={3}>
|
||||
{/* The bearing and the two distances are read-only and hold at most
|
||||
"12345 km", so they take a fixed width and the address — the one
|
||||
field here that is never wide enough — gets everything left over. */}
|
||||
<div className="col-span-12 flex gap-2">
|
||||
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceShort) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('detp.qslMessage')} span={7}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslVia')} span={2}>
|
||||
<Field label={t('detp.qslVia')} span={5}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -305,12 +391,39 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||
heightClass="flex-1 min-h-0"
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||
{/* Ambiguous candidates: the award allows one reference per QSO and
|
||||
several matched, so none was kept. Click one to settle it while the
|
||||
contact is still in front of you. */}
|
||||
{detected.some((r) => r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
||||
{detected.filter((r) => r.ambiguous).map((r) => (
|
||||
<button
|
||||
key={`amb-${r.code}@${r.ref}`}
|
||||
type="button"
|
||||
title={r.name ?? ''}
|
||||
onClick={() => {
|
||||
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
||||
const cur = details.award_refs ?? '';
|
||||
// Only one of the candidates can be right — replace any
|
||||
// sibling already picked for this award rather than stacking.
|
||||
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
||||
onChange({ award_refs: [...kept, entry].join(';') });
|
||||
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
||||
}}
|
||||
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detected.some((r) => !r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||
<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}
|
||||
{detected.filter((r) => !r.ambiguous).map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -352,18 +465,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label={t('detp.satName')} span={3}>
|
||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.satelliteMode')} span={3}>
|
||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -126,13 +126,13 @@ export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; o
|
||||
{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-rose-500/10')}>
|
||||
<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-emerald-600">{t('dup.keep')}</span>}
|
||||
{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>
|
||||
@@ -151,7 +151,7 @@ export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; o
|
||||
<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-red-600 px-3 py-1.5 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-40">
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -12,21 +12,27 @@ 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) {
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||
const { t } = useI18n();
|
||||
const anyAudio = messages.some((m) => m.has_audio);
|
||||
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">{t('dvkp.voiceKeyer')}</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={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" /> {t('dvkp.stop')}
|
||||
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
{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 ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
||||
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 ? t('dvkp.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,112 @@
|
||||
import React from 'react';
|
||||
import { LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// A render error used to empty the window and leave nothing behind: no line in
|
||||
// opslog.log, no dialog, no way for the operator to say more than "white screen".
|
||||
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
|
||||
// auto-call — stayed undiagnosed for weeks for exactly that reason.
|
||||
//
|
||||
// So this does two things, in order of importance:
|
||||
//
|
||||
// 1. Writes the error and its stack to the app log through LogUIError, so the
|
||||
// next report arrives WITH the cause instead of a description of a blank
|
||||
// screen.
|
||||
// 2. Shows it, with the text selectable and a button to copy it, because the
|
||||
// person who can act on it is the one looking at the screen.
|
||||
//
|
||||
// It deliberately does NOT try to recover the tree. React cannot guarantee the
|
||||
// state is sane after a render throw, and a half-working logger is worse than
|
||||
// one that says plainly what happened and offers a reload.
|
||||
|
||||
type Props = { children: React.ReactNode };
|
||||
type State = { error: Error | null; info: string };
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null, info: '' };
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
const stack = (info?.componentStack || error.stack || '').trim();
|
||||
this.setState({ info: stack });
|
||||
// Never let the reporting path throw: it runs while the app is already broken.
|
||||
try {
|
||||
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
|
||||
} catch {
|
||||
/* the backend may be gone too — the on-screen copy still works */
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, info } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const report = `${error.name}: ${error.message}\n\n${info}`;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
overflow: 'auto', zIndex: 9999,
|
||||
}}>
|
||||
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||
OpsLog hit an error and stopped drawing
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||
Your log is safe — this is the window, not the database. The details below
|
||||
are already in the app log; send them with your report.
|
||||
</p>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||
}}>{report}</pre>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||
<button
|
||||
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Copy the details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
Reload the window
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
|
||||
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
|
||||
// so they leave no trace at all — and an auto-call loop lives entirely in
|
||||
// callbacks, which is precisely where a white screen was reported.
|
||||
export function installGlobalErrorLogging() {
|
||||
const report = (kind: string, message: string, stack?: string) => {
|
||||
try {
|
||||
LogUIError(kind, message, stack || '');
|
||||
} catch {
|
||||
/* nothing more we can do from here */
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('error', (e: ErrorEvent) => {
|
||||
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
|
||||
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const r: any = e.reason;
|
||||
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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';
|
||||
|
||||
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
|
||||
// inside ExportFieldsDialog) so its component identity is stable across renders —
|
||||
// an inner component is re-created every render, remounting the whole subtree and
|
||||
// making the All/None buttons and checkboxes feel dead.
|
||||
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
|
||||
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
|
||||
allLabel: string; noneLabel: string;
|
||||
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<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={() => onAll(tags)}>{allLabel}</button>
|
||||
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</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) => onToggle(name, !!c)} />
|
||||
<span className="font-mono break-all">{name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 allLabel = t('exf.all');
|
||||
const noneLabel = t('exf.none');
|
||||
const cardProps = {
|
||||
sel, allLabel, noneLabel,
|
||||
onAll: (tags: string[]) => setMany(tags, true),
|
||||
onNone: (tags: string[]) => setMany(tags, false),
|
||||
onToggle: toggle,
|
||||
};
|
||||
|
||||
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 {...cardProps} />}
|
||||
{groups.map(([g, list]) => (
|
||||
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
|
||||
))}
|
||||
</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,4 +1,4 @@
|
||||
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';
|
||||
@@ -26,7 +26,9 @@ export interface QueryFilter {
|
||||
|
||||
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
// 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
|
||||
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
|
||||
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
|
||||
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
|
||||
// of them — a paper QSL is not uploaded.
|
||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
|
||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
|
||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
|
||||
{ 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: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
|
||||
|
||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
|
||||
// comparison operators (before/after), not the text ones.
|
||||
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||
return OPS.filter((o) => allow.includes(o.value));
|
||||
}
|
||||
@@ -134,6 +152,12 @@ 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>>({});
|
||||
@@ -210,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<SelectItem key={n} value={n}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{n}
|
||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
||||
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||
fires too late — the menu has already loaded the preset and
|
||||
closed. Intercept pointer-down (preventDefault stops Radix
|
||||
claiming it) and delete there instead. */}
|
||||
<Trash2
|
||||
className="size-3 text-muted-foreground hover:text-destructive"
|
||||
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||
/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -237,7 +268,7 @@ 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}>{t(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 })}>
|
||||
@@ -247,12 +278,23 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
// An ADIF date is picked with a calendar but STORED as the
|
||||
// 8-digit form the column holds, so the comparison stays a
|
||||
// plain string one on both sides.
|
||||
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
// \d, not d: the escape was missing, so the test never matched
|
||||
// an 8-digit ADIF date and the calendar input was handed
|
||||
// "20260728" — which type=date rejects, showing an empty box
|
||||
// over a value that was really there.
|
||||
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
||||
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
||||
: c.value}
|
||||
onChange={(e) => setCond(i, {
|
||||
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||
})}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||
|
||||
@@ -4,7 +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 } from '@/lib/i18n';
|
||||
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';
|
||||
|
||||
@@ -14,7 +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 } = useI18n();
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const [p, setP] = useState<Profile | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -68,6 +68,22 @@ 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">{t('frm.welcome')}</h2>
|
||||
@@ -77,10 +93,10 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||
<Label className="text-sm">{t('frm.callsign')} <span className="text-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">{t('frm.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">{t('frm.operator')}</Label>
|
||||
@@ -104,10 +120,10 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
{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">{t('frm.required')}</span>}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react';
|
||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
||||
import {
|
||||
GetIcomState, IcomRefresh,
|
||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
|
||||
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';
|
||||
@@ -17,18 +21,117 @@ type IcomState = {
|
||||
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;
|
||||
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,
|
||||
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.
|
||||
type Band = { l: string; hz: number };
|
||||
|
||||
const HF_BANDS: Band[] = [
|
||||
{ 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 },
|
||||
];
|
||||
const B6 = { l: '6', hz: 50_150_000 };
|
||||
const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
||||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed.
|
||||
//
|
||||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||
// through 6 and none of the bands it actually covers: ten dead buttons, and no
|
||||
// way to change band from here on the only rig where you would want to.
|
||||
function bandsFor(model?: string): Band[] {
|
||||
const m = (model ?? '').toUpperCase();
|
||||
if (m.includes('9700')) return [B2, B70, B23]; // VHF/UHF/SHF only
|
||||
if (m.includes('705')) return [...HF_BANDS, B6, B2, B70];
|
||||
if (m.includes('9100')) return [...HF_BANDS, B6, B2, B70, B23];
|
||||
// 7300 / 7610 / 7100 / unknown: HF + 6 m, the historical list.
|
||||
return [...HF_BANDS, B6];
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// bandOfHz names the amateur band a frequency falls in, so the band row can show
|
||||
// where the rig actually is. The buttons only ever SENT a frequency and carried
|
||||
// no active state at all, so nothing was highlighted whatever the rig reported.
|
||||
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
|
||||
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
|
||||
function bandOfHz(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '';
|
||||
const mhz = hz / 1_000_000;
|
||||
const bands: [string, number, number][] = [
|
||||
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
|
||||
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
|
||||
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
||||
// Labels must match the band buttons' exactly — this is only used to light
|
||||
// the button for the band the rig is on, and '70' never matched '70cm'.
|
||||
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0],
|
||||
['70cm', 430.0, 450.0], ['23cm', 1240.0, 1300.0],
|
||||
];
|
||||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||
return '';
|
||||
}
|
||||
|
||||
// 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;
|
||||
}) {
|
||||
@@ -61,7 +164,7 @@ function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||||
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-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||||
'[&::-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 }}
|
||||
/>
|
||||
);
|
||||
@@ -87,7 +190,7 @@ function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label:
|
||||
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-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -145,6 +248,39 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
|
||||
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.
|
||||
@@ -157,23 +293,63 @@ function sParts(v: number): { s: number; over: number; label: string } {
|
||||
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 on a canvas. The amplitude array is raw rig scale (~0-160);
|
||||
// we normalise to the tallest recent peak so the trace fills the height.
|
||||
// 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(() => {});
|
||||
@@ -198,7 +374,7 @@ function ScopePanadapter() {
|
||||
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, 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;
|
||||
@@ -236,7 +412,7 @@ function ScopePanadapter() {
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [on]);
|
||||
|
||||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => {
|
||||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -245,66 +421,152 @@ function ScopePanadapter() {
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// 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,120,120,0.15)';
|
||||
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(); }
|
||||
|
||||
// Spectrum trace, filled under the line.
|
||||
const n = amp.length;
|
||||
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++) {
|
||||
const x = (i / (n - 1)) * w;
|
||||
const y = h - Math.min(1, amp[i] / scale) * h;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
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.55)');
|
||||
grad.addColorStop(1, 'rgba(56,189,248,0.05)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#38bdf8';
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.stroke();
|
||||
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
||||
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
||||
ctx.fillStyle = grad; ctx.fill();
|
||||
|
||||
// VFO marker: a vertical line at the current frequency within the span.
|
||||
if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) {
|
||||
const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
||||
ctx.strokeStyle = 'rgba(239,68,68,0.95)';
|
||||
ctx.lineWidth = 1.5;
|
||||
// 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 (edges + centre), only when the rig reported the span.
|
||||
if (lowHz > 0 && highHz > lowHz) {
|
||||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
||||
ctx.fillStyle = 'rgba(226,232,240,0.75)';
|
||||
ctx.font = '10px ui-monospace, monospace';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2);
|
||||
ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2);
|
||||
ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2);
|
||||
// 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 (
|
||||
<Card icon={Activity} title={t('icmp.spectrum')} accent="#38bdf8">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground truncate">
|
||||
{on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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')} />
|
||||
@@ -312,11 +574,16 @@ function ScopePanadapter() {
|
||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-black/70 overflow-hidden">
|
||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||||
className="w-full block cursor-crosshair" style={{ height: 150 }} />
|
||||
</div>
|
||||
</Card>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,19 +591,24 @@ function ScopePanadapter() {
|
||||
// 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 }: { onReportRST?: (rst: string) => void } = {}) {
|
||||
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<IcomState>(ZERO);
|
||||
const [busy, setBusy] = useState(false);
|
||||
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(() => {});
|
||||
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 () => {
|
||||
setBusy(true);
|
||||
try { await IcomRefresh(); } catch {}
|
||||
await load();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -364,6 +636,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
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">
|
||||
@@ -373,6 +666,17 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -381,36 +685,123 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
<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-red-600 text-white' : 'bg-emerald-600 text-white')}>
|
||||
<span className={cn('size-2 rounded-full', tx ? 'bg-white animate-pulse' : 'bg-white/90')} />
|
||||
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-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</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>
|
||||
<button type="button" onClick={refresh} disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
|
||||
</button>
|
||||
<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">
|
||||
{/* Live meters. */}
|
||||
<Card icon={Zap} title={t('icmp.meters')} accent="#f59e0b">
|
||||
{tx ? (
|
||||
<>
|
||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
|
||||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||||
</>
|
||||
) : (() => { 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} />
|
||||
); })()}
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{bandsFor(st.model).map((b) => {
|
||||
const here = bandOfHz(mainHz) === b.l;
|
||||
return (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
here
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
|
||||
: 'border-border bg-card text-foreground hover:bg-muted')}>
|
||||
{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. */}
|
||||
@@ -419,23 +810,46 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
<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>
|
||||
<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>
|
||||
{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-red-600 border-red-600 text-white' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||
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-amber-500 border-amber-500 text-white animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||
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">
|
||||
@@ -447,6 +861,10 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
<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))} />
|
||||
@@ -456,7 +874,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label="Att">
|
||||
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
||||
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label={t('icmp.filter')}>
|
||||
@@ -465,6 +883,31 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
</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))}
|
||||
@@ -476,6 +919,13 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
||||
|
||||
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
||||
//
|
||||
// This exists because diagnosing a problem used to mean asking the operator to
|
||||
// find a file in their data directory, open it in a text editor, and re-open it
|
||||
// after every attempt. Half the round-trips in a bug report were spent on that.
|
||||
//
|
||||
// Three behaviours matter more than they look:
|
||||
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
||||
// moment they scroll up — otherwise the line they are reading is yanked away
|
||||
// a second later, which defeats the one thing this window is for. Scrolling
|
||||
// back to the bottom re-arms it, so following is never a dead end.
|
||||
// • The filter keeps only matching LINES rather than highlighting inside a
|
||||
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||
// the subsystem trace you would have grepped for.
|
||||
// • It polls only while open. A closed dialog costs nothing.
|
||||
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
|
||||
// the oldest fall out of the fetched text entirely, so a small buffer dropped
|
||||
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
|
||||
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
|
||||
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
|
||||
// useful backlog while staying smooth. The backend caps this at 4 MB.
|
||||
const TAIL_BYTES = 512 * 1024;
|
||||
const POLL_MS = 1000;
|
||||
|
||||
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
||||
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
||||
const [lastPull, setLastPull] = useState('');
|
||||
const boxRef = useRef<HTMLPreElement>(null);
|
||||
const autoRef = useRef(true);
|
||||
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
||||
|
||||
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
||||
// straight away is not enough while the dialog is still animating in: the box
|
||||
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
||||
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
||||
// at lines from hours ago, which looks exactly like a frozen window.
|
||||
const stick = useCallback(() => {
|
||||
if (!autoRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const el = boxRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pull = useCallback(() => {
|
||||
TailLogFile(TAIL_BYTES).then((s: any) => {
|
||||
setText(String(s ?? ''));
|
||||
setLastPull(new Date().toLocaleTimeString());
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAutoScroll(true);
|
||||
autoRef.current = true;
|
||||
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
||||
pull();
|
||||
// Re-assert the bottom after the open animation has settled. A quiet log
|
||||
// never changes, so the content-driven scroll below would never fire again
|
||||
// and the first attempt would be the only one.
|
||||
const t1 = window.setTimeout(stick, 120);
|
||||
const t2 = window.setTimeout(stick, 450);
|
||||
const id = window.setInterval(pull, POLL_MS);
|
||||
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
||||
}, [open, pull, stick]);
|
||||
|
||||
const all = useMemo(() => text.split('\n'), [text]);
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((l) => l.toLowerCase().includes(q));
|
||||
}, [all, query]);
|
||||
|
||||
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
||||
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
||||
|
||||
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
||||
function onScroll() {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
||||
}
|
||||
|
||||
const body = shown.join('\n');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('logview.title')}</DialogTitle>
|
||||
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
||||
onChange={(e) => setQuery(e.target.value)} />
|
||||
{query.trim() !== '' && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{t('logview.matches', { n: shown.length, total: all.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre ref={boxRef} onScroll={onScroll}
|
||||
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
||||
{body.trim() === '' ? t('logview.empty') : body}
|
||||
</pre>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
||||
const on = !!c;
|
||||
setAutoScroll(on);
|
||||
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
||||
}} />
|
||||
{t('logview.autoScroll')}
|
||||
</label>
|
||||
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
||||
{t('logview.copy')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
||||
{t('logview.toEnd')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { nightPolygon } from '../lib/greyline';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
@@ -42,9 +43,20 @@ function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||
}
|
||||
|
||||
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||
const OSM_ATTR = '© OpenStreetMap contributors';
|
||||
|
||||
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
||||
// and their usage policy does not cover a desktop application handed to an
|
||||
// unbounded number of operators — they blocked OpsLog for it, and every user's
|
||||
// map filled with "Access blocked / 403" tiles at once.
|
||||
//
|
||||
// The replacements are tile services whose terms do cover redistributed apps,
|
||||
// with no API key: Carto (whose tiles are OSM-derived, hence the attribution
|
||||
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
||||
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
||||
// are allowed to ship.
|
||||
const ESRI_STREET = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
||||
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors';
|
||||
|
||||
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||
@@ -54,7 +66,7 @@ const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; s
|
||||
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
|
||||
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_ATTR },
|
||||
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||
@@ -64,6 +76,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: '',
|
||||
@@ -97,17 +127,26 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||
// across restarts. Default on.
|
||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||
|
||||
// Grey line — the day/night terminator with its twilight band. Off by
|
||||
// default: it is a working tool for the operator who wants it, not decoration
|
||||
// for the one who does not.
|
||||
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||
const autoZoomRef = useRef(autoZoom);
|
||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||
|
||||
// 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();
|
||||
@@ -115,7 +154,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
|
||||
@@ -123,14 +167,83 @@ 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]);
|
||||
|
||||
// Draw the grey line, and keep it moving.
|
||||
//
|
||||
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||
// time, which is invisible, but a map left on all day would otherwise be
|
||||
// silently wrong by evening — and the whole point of the display is knowing
|
||||
// where the line is NOW.
|
||||
//
|
||||
// It lives in its own pane below the overlay pane so the path and beam are
|
||||
// never hidden behind the shading, and is non-interactive so it can never
|
||||
// swallow a click meant for the map.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) return;
|
||||
|
||||
if (!greyline) {
|
||||
greylineLayer.current?.remove();
|
||||
greylineLayer.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m.getPane('greyline')) {
|
||||
const pane = m.createPane('greyline');
|
||||
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||
pane.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const now = new Date();
|
||||
greylineLayer.current?.remove();
|
||||
const g = L.layerGroup([], { pane: 'greyline' });
|
||||
|
||||
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||
// begins — the shading looked like a rectangle laid over the planet.
|
||||
//
|
||||
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||
// cover every zoom level this map reaches; they cost nothing, being three
|
||||
// polygons on a canvas layer.
|
||||
const COPIES = [-360, 0, 360];
|
||||
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||
|
||||
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||
// which is the band operators actually mean by "grey line".
|
||||
const nightRing = nightPolygon(now, 0);
|
||||
const twilightRing = nightPolygon(now, -6);
|
||||
// The terminator itself, so the line is readable at a glance rather than
|
||||
// being inferred from where the shading fades. Dropping the last two
|
||||
// points leaves the terminator alone, without the segment that closes the
|
||||
// polygon along the dark pole.
|
||||
const lineRing = nightRing.slice(0, -2);
|
||||
|
||||
for (const by of COPIES) {
|
||||
L.polygon(shifted(twilightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polygon(shifted(nightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polyline(shifted(lineRing, by), {
|
||||
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||
}).addTo(g);
|
||||
}
|
||||
g.addTo(m);
|
||||
greylineLayer.current = g;
|
||||
};
|
||||
|
||||
draw();
|
||||
const id = window.setInterval(draw, 60_000);
|
||||
return () => { window.clearInterval(id); };
|
||||
}, [greyline]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
useEffect(() => {
|
||||
const wm = worldMap.current, wo = worldOverlay.current;
|
||||
@@ -224,9 +337,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
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, zoomSignal]);
|
||||
}, [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);
|
||||
|
||||
@@ -267,6 +385,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{/* Grey line toggle, under the zoom button. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !greyline;
|
||||
setGreyline(v);
|
||||
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||
}}
|
||||
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Grey line
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
@@ -294,7 +427,14 @@ export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
||||
if (locatorRef.current && !locatorMap.current) {
|
||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||
.setView([20, 0], 2);
|
||||
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
||||
// Same restraint as the world map: fetch on idle, not mid-pan, and keep a
|
||||
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
||||
// moves is what gets an app blocked, and the next provider is under no
|
||||
// more obligation to tolerate it than the last one was.
|
||||
L.tileLayer(CARTO_LIGHT, {
|
||||
attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19,
|
||||
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||
}).addTo(m);
|
||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||
locatorMap.current = m;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MenuItem =
|
||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
|
||||
// accent highlights an item that is an OFFER rather than a command (Donate).
|
||||
// Generic on purpose: a hard-coded label test in the renderer would break the
|
||||
// moment the menu is translated.
|
||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
|
||||
| { type: 'separator' };
|
||||
|
||||
export interface Menu {
|
||||
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
|
||||
onSelect={() => onAction(item.action)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
|
||||
// cards and the Tuner Genius card, so every meter renders at exactly the same size
|
||||
// (segment count, bar height, padding). Keep it the single source of truth — don't
|
||||
// re-declare a local copy in a panel, or the meters drift out of sync.
|
||||
export const METER_SEGMENTS = 26;
|
||||
|
||||
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||
}) {
|
||||
const span = hi - lo;
|
||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||
return (
|
||||
<div onClick={onClick} title={title}
|
||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||
{display !== undefined ? display : (
|
||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||
const on = i < lit;
|
||||
const frac = i / METER_SEGMENTS;
|
||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||
return (
|
||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||
style={on
|
||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
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 } from 'lucide-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';
|
||||
@@ -13,38 +14,20 @@ 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 = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
type Net = netctl.Net;
|
||||
type Station = netctl.Station;
|
||||
@@ -59,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
||||
|
||||
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 }: Props) {
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [nets, setNets] = useState<Net[]>([]);
|
||||
const [selId, setSelId] = useState<string>('');
|
||||
@@ -77,14 +74,74 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
|
||||
// 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 activeGrid = useRef<any>(null);
|
||||
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;
|
||||
|
||||
@@ -109,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
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]);
|
||||
@@ -164,10 +268,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
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;
|
||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
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).
|
||||
@@ -192,6 +324,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
...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); }
|
||||
@@ -215,20 +350,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
||||
], [t]);
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
// 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 },
|
||||
@@ -257,12 +381,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
<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-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||
<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-emerald-600 text-white tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
||||
{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>
|
||||
@@ -273,42 +397,94 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
<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-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{t('ncp.onAirActive')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<QSOForm>
|
||||
ref={activeGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={active}
|
||||
columnDefs={activeCols}
|
||||
defaultColDef={defaultColDef}
|
||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
<div 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={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||
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-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{t('ncp.netUsersRoster')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<Station>
|
||||
ref={rosterGrid}
|
||||
@@ -317,6 +493,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
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)}
|
||||
@@ -327,7 +505,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
<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-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
||||
<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 && (
|
||||
|
||||
@@ -151,7 +151,7 @@ export function OperatingPanel({ bands, onError }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
{t('op.intro1')}<Star className="inline size-3 text-amber-500 fill-current align-text-bottom" />{t('op.intro2')}
|
||||
{t('op.intro1')}<Star className="inline size-3 text-warning fill-current align-text-bottom" />{t('op.intro2')}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -411,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'
|
||||
@@ -430,8 +430,8 @@ 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 ? t('op.defaultOn') : t('op.defaultOff')}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
|
||||
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';
|
||||
@@ -8,7 +8,7 @@ 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, CancelConfirmations, 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';
|
||||
@@ -16,15 +16,6 @@ import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Warm theme matching the other grids (Recent QSOs / Cluster).
|
||||
const qslTheme = 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,
|
||||
});
|
||||
|
||||
type UploadRow = {
|
||||
id: number; qso_date: string; callsign: string;
|
||||
band: string; mode: string; country: string; status: string;
|
||||
@@ -41,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||
|
||||
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 = [
|
||||
@@ -129,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||
}, [service]);
|
||||
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||
// bleed its log into the next one.
|
||||
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||
const [potaErr, setPotaErr] = useState('');
|
||||
@@ -217,18 +212,27 @@ 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 serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
@@ -240,9 +244,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -362,6 +367,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
</>
|
||||
)}
|
||||
<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">
|
||||
{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}
|
||||
@@ -380,6 +401,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<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>
|
||||
@@ -399,7 +421,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
|
||||
{/* 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 ? (
|
||||
@@ -409,6 +431,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
// 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
|
||||
// Its OWN column layout. Without a storageKey this fell back to
|
||||
// the main log's key, so every resize or hidden column here
|
||||
// silently rewrote the Recent QSOs layout, and vice versa.
|
||||
storageKey="qslmgr.paper"
|
||||
rows={paperRows as any}
|
||||
total={paperRows.length}
|
||||
selectAllSignal={paperSelAllSig}
|
||||
@@ -425,7 +451,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
{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">
|
||||
<div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
|
||||
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
|
||||
{potaRes.skipped_other_call > 0 && (
|
||||
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
||||
@@ -469,8 +495,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<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" /> {t('qslm.working')}</div>}
|
||||
@@ -498,9 +524,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">{t('qslm.newDxcc')}</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">{t('qslm.newBand')}</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">{t('qslm.newSlot')}</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>
|
||||
@@ -513,6 +540,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
) : (
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
storageKey="qslmgr.upload"
|
||||
rows={rows as any}
|
||||
total={rows.length}
|
||||
selectAllSignal={uploadSelAllSig}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
@@ -35,8 +36,14 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||
// case where it already fits.
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -49,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
// Position AFTER measuring.
|
||||
//
|
||||
// The menu used to be clamped against a guessed height — 230 px when the
|
||||
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||
// a selection, so right-clicking near the bottom of the window cut half the
|
||||
// entries off. Its height also depends on which actions the caller passes, so
|
||||
// no constant can be right for long.
|
||||
//
|
||||
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||
// and failing that pinned to the top with its own scrollbar.
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !boxRef.current) return;
|
||||
const r = boxRef.current.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
let x = menu.x;
|
||||
let y = menu.y;
|
||||
if (x + r.width > window.innerWidth - pad) {
|
||||
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||
}
|
||||
if (y + r.height > window.innerHeight - pad) {
|
||||
// Above the cursor, if there is room there.
|
||||
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||
}
|
||||
setPos({ x, y });
|
||||
}, [menu, onSendTo]);
|
||||
|
||||
if (!menu) return null;
|
||||
const n = menu.ids.length;
|
||||
// Keep the menu on-screen near the cursor.
|
||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: x, top: y }}
|
||||
ref={boxRef}
|
||||
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
@@ -75,7 +106,7 @@ 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={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||
>
|
||||
<RefreshCw className="size-4 text-sky-600" />
|
||||
<RefreshCw className="size-4 text-info" />
|
||||
<span>{t('qctx.updateQrz')}</span>
|
||||
</button>
|
||||
{onUpdateFromClublog && (
|
||||
@@ -83,7 +114,7 @@ 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={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||
>
|
||||
<BadgeCheck className="size-4 text-violet-600" />
|
||||
<BadgeCheck className="size-4 text-info" />
|
||||
<span>{t('qctx.updateClublog')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -96,7 +127,7 @@ 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" />
|
||||
<Mail className="size-4 text-warning" />
|
||||
<span>{t('qctx.sendQslEmail')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -105,7 +136,7 @@ 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" />
|
||||
<Mail className="size-4 text-danger" />
|
||||
<span>{t('qctx.sendRecording')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -119,7 +150,7 @@ 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={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||
>
|
||||
<PencilLine className="size-4 text-indigo-600" />
|
||||
<PencilLine className="size-4 text-info" />
|
||||
<span>{t('qctx.bulkEdit', { n })}</span>
|
||||
</button>
|
||||
</>
|
||||
@@ -133,16 +164,25 @@ 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" />
|
||||
<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 && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-violet-600" />
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -151,7 +191,7 @@ 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={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-amber-600" />
|
||||
<FileDown className="size-4 text-warning" />
|
||||
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -160,7 +200,7 @@ 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={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-amber-700" />
|
||||
<FileDown className="size-4 text-warning" />
|
||||
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -176,7 +216,7 @@ 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={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
||||
>
|
||||
<Upload className="size-4 text-emerald-600" />
|
||||
<Upload className="size-4 text-success" />
|
||||
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -187,7 +227,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<>
|
||||
<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-rose-700 hover:bg-rose-50"
|
||||
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" />
|
||||
|
||||
@@ -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 { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } 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';
|
||||
@@ -21,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||
import type { QSOForm } from '@/types';
|
||||
|
||||
type QSO = QSOForm;
|
||||
@@ -36,7 +38,7 @@ function pfxOf(call: string): string {
|
||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||
}
|
||||
|
||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
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 = [
|
||||
@@ -71,6 +73,11 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
QSL: 'qedit.confQslPaper',
|
||||
};
|
||||
|
||||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||
// the channel picker and the status table alongside the rest.
|
||||
const OPSLOG_CONF = 'OPSLOG';
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -80,11 +87,23 @@ function StatusCell({ value }: { value?: string }) {
|
||||
if (v === '') {
|
||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||
}
|
||||
// One colour per state, and each colour means something:
|
||||
// Yes green — confirmed, the thing you wanted
|
||||
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||
// This is the ONLY state asking for action, so it gets the
|
||||
// only alarming colour.
|
||||
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||||
// every row; painting that orange (as it used to be, in the
|
||||
// same orange as Requested) made the table shout about a
|
||||
// non-problem and told you nothing apart.
|
||||
// Ignore dashed — deliberately excluded, on purpose.
|
||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
||||
: v === 'R' ? 'bg-orange-400 text-white'
|
||||
: v === 'I' ? 'bg-stone-400 text-white'
|
||||
: 'bg-amber-400 text-amber-950';
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -135,6 +154,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
||||
);
|
||||
}
|
||||
|
||||
// AdifDateInput — a real date picker over an ADIF date.
|
||||
//
|
||||
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
|
||||
// are converted at the edge and the log keeps its ADIF form. The native control
|
||||
// is used on purpose: it brings the OS calendar, the locale's day/month order
|
||||
// and keyboard entry for free, which a hand-rolled popover would have to
|
||||
// reimplement and get wrong.
|
||||
//
|
||||
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
|
||||
// a plain text box instead, so it stays visible and correctable rather than
|
||||
// silently disappearing behind an empty picker.
|
||||
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
|
||||
const raw = (value ?? '').trim();
|
||||
const valid = /^\d{8}$/.test(raw);
|
||||
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
|
||||
const today = () => {
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
// UTC: every date in the log is UTC, and near midnight the local day is the
|
||||
// wrong one.
|
||||
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
|
||||
};
|
||||
if (raw !== '' && !valid) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={iso}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value; // "" when cleared
|
||||
onChange(v ? v.replace(/-/g, '') : '');
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
|
||||
onClick={today} title="Today (UTC)">
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -163,6 +232,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') }
|
||||
@@ -187,6 +263,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.
|
||||
@@ -207,6 +286,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 */ }
|
||||
})
|
||||
@@ -256,19 +336,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
const r: any = await LookupCallsign(call);
|
||||
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
|
||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||
// appeared to do nothing.
|
||||
const r: any = await LookupCallsignFresh(call);
|
||||
// The lookup WINS over what is in the record — that is the point of asking
|
||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||
// record with no grid used to wipe the grid that was already there.
|
||||
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: r.name ?? d.name,
|
||||
qth: r.qth ?? d.qth,
|
||||
address: r.address ?? (d as any).address,
|
||||
email: r.email ?? (d as any).email,
|
||||
country: r.country ?? d.country,
|
||||
grid: r.grid ?? d.grid,
|
||||
state: r.state ?? d.state,
|
||||
cnty: r.cnty ?? d.cnty,
|
||||
cont: r.cont ?? d.cont,
|
||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
||||
name: keep(r.name, d.name),
|
||||
qth: keep(r.qth, d.qth),
|
||||
address: keep(r.address, (d as any).address),
|
||||
email: keep(r.email, (d as any).email),
|
||||
country: keep(r.country, d.country),
|
||||
grid: keep(r.grid, d.grid),
|
||||
state: keep(r.state, d.state),
|
||||
cnty: keep(r.cnty, d.cnty),
|
||||
cont: keep(r.cont, d.cont),
|
||||
qsl_via: keep(r.qsl_via, d.qsl_via),
|
||||
dxcc: r.dxcc || d.dxcc,
|
||||
cqz: r.cqz || d.cqz,
|
||||
ituz: r.ituz || d.ituz,
|
||||
@@ -327,7 +416,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);
|
||||
}
|
||||
|
||||
@@ -345,9 +434,29 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
[draft.extras],
|
||||
);
|
||||
|
||||
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
|
||||
// received → TNX QSL (thanks for your card), otherwise PSE QSL (please send one).
|
||||
// Saved with the normal Save via draft.extras.
|
||||
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
|
||||
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
|
||||
// Sent side, for the confirmations table. Two key names because the marker was
|
||||
// renamed once and old QSOs still carry the first one — same test the Recent
|
||||
// QSOs "OpsLog QSL" column makes.
|
||||
const opslogQslSent = !!(draft.extras?.['APP_OPSLOG_QSL_SENT'] || draft.extras?.['APP_OPSLOG_QSL_CARD_SENT']);
|
||||
const toggleQslReceived = (on: boolean) => {
|
||||
// Reflect immediately in the draft (for the PSE/TNX indicator)…
|
||||
const next = { ...(draft.extras ?? {}) };
|
||||
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
|
||||
else delete next[OPSLOG_QSL_RCVD];
|
||||
set('extras', next as any);
|
||||
// …and persist right away with a targeted write that reliably sets OR clears
|
||||
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
|
||||
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
|
||||
};
|
||||
|
||||
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>{t('qedit.title')}</DialogTitle>
|
||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||
@@ -383,14 +492,29 @@ 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>{t('qedit.callsign')}</Label>
|
||||
<div className="flex items-center">
|
||||
<Label>{t('qedit.callsign')}</Label>
|
||||
{(draft.callsign ?? '').trim() && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
|
||||
onClick={() => {
|
||||
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||||
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
|
||||
}}
|
||||
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||||
>
|
||||
QRZ ↗
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
@@ -400,7 +524,8 @@ 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>{t('qedit.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)}
|
||||
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||
@@ -466,8 +591,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
||||
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
|
||||
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -488,7 +615,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
|
||||
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
|
||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
</div>
|
||||
{/* Right column */}
|
||||
@@ -498,7 +626,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
{/* The OpsLog QSL marker used to sit here, under QSL Msg. It
|
||||
belongs with the other confirmation channels — see the QSL
|
||||
Info tab. */}
|
||||
<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>
|
||||
@@ -546,30 +680,78 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<Label>{t('qedit.manageConf')}</Label>
|
||||
<Select value={confSel} onValueChange={setConfSel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
||||
<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>)}
|
||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
||||
QSO columns, and this channel lives in the ADIF
|
||||
extras. It gets its own editor below rather than the
|
||||
generic sent/received/date grid, which has no field
|
||||
to bind to. */}
|
||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label>
|
||||
{def.rcvd
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
|
||||
{confSel === OPSLOG_CONF ? (
|
||||
/* OpsLog's own card. "Sent" is stamped when the card
|
||||
actually goes out, so it is shown, not offered: ticking
|
||||
it by hand would record something that never happened.
|
||||
"Received" drives the PSE/TNX stamp printed on the card,
|
||||
which is the whole reason the flag exists — hence the
|
||||
live indicator next to it. It writes immediately rather
|
||||
than on Save, because clearing an extras key would not
|
||||
survive the merge Save does. */
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('qedit.sent')}</Label>
|
||||
<Input disabled value={opslogQslSent ? t('qedit.qslYes') : t('qedit.qslNo')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('qedit.received')}</Label>
|
||||
<label className="flex h-9 items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
|
||||
{t('qedit.qslReceived')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{qslReceived ? 'TNX QSL' : 'PSE QSL'}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('qedit.pseTnxHint')}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('qedit.opslogSentHint')}</p>
|
||||
</div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
{def.via && (
|
||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label>
|
||||
{def.rcvd
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
</div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||
{def.via && (
|
||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: live status grid for every channel */}
|
||||
<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">{t('qedit.thType')}</th>
|
||||
@@ -580,11 +762,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<tbody>
|
||||
{CONFIRMATIONS.map((c) => (
|
||||
<tr key={c.key} className="text-xs">
|
||||
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||
<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>
|
||||
))}
|
||||
{/* OpsLog's own card, read from the ADIF extras rather
|
||||
than a QSO column — hence a hand-written row instead
|
||||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
||||
when the card actually goes out, so it stays
|
||||
read-only here: an operator ticking it by hand would
|
||||
be recording something that never happened. */}
|
||||
<tr className="text-xs">
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 { qslStatusCellClass } from '@/lib/qslStatus';
|
||||
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
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 {
|
||||
@@ -14,43 +17,49 @@ 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';
|
||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||
|
||||
// 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;
|
||||
// Operator's CURRENT locator — fallback for the distance column on older QSOs
|
||||
// that carry no my_grid of their own.
|
||||
myGrid?: string;
|
||||
// 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;
|
||||
// The full first selected row — the Stats (F1) panel shows its entity when the
|
||||
// entry form is empty, so browsing the log answers "what have I got with this
|
||||
// one?" without retyping the call.
|
||||
onRowSelectedQso?: (row: QSOForm | null) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
@@ -59,16 +68,21 @@ type Props = {
|
||||
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 '';
|
||||
@@ -77,23 +91,13 @@ function fmtMhzDots(hz?: number): string {
|
||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
// Both formatters follow the display preference (Settings → General). What is
|
||||
// STORED never changes — see lib/dateFormat.
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
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())}`;
|
||||
return formatDateTimeUTC(s);
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
return formatDateOnly(s);
|
||||
}
|
||||
|
||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||
@@ -107,11 +111,30 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
||||
// 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[] => [
|
||||
// qsoDistanceKm returns the great-circle distance for one row, in km.
|
||||
//
|
||||
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
|
||||
// portable outings, so the station the QSO was made from is not necessarily the
|
||||
// one configured today. `myGrid` (the current profile) is only the fallback for
|
||||
// the many older records that carry no my_* fields at all.
|
||||
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
||||
if (!d) return undefined;
|
||||
const here =
|
||||
(d.my_grid && gridToLatLon(d.my_grid)) ||
|
||||
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
|
||||
(myGrid ? gridToLatLon(myGrid) : null);
|
||||
const there =
|
||||
(d.grid && gridToLatLon(d.grid)) ||
|
||||
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
|
||||
if (!here || !there) return undefined;
|
||||
return Math.round(pathBetweenLatLon(here, there).distanceShort);
|
||||
}
|
||||
|
||||
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
// ── QSO basics ──
|
||||
{ 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', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||
{ 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 },
|
||||
@@ -142,12 +165,17 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{ 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' },
|
||||
// Derived, not stored: computed from the two locations at display time, like
|
||||
// the cluster grid's own distance column.
|
||||
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ 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: 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'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ 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 },
|
||||
@@ -155,32 +183,34 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{ 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: 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'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ 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: 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'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
{ 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: qslStatusCellClass, 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: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
// 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 },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, 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: 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'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ 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'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ 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'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ 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 , cellClass: qslStatusCellClass },
|
||||
{ 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) },
|
||||
|
||||
@@ -222,6 +252,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{ 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: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
@@ -245,14 +282,40 @@ const GRP_KEYS: Record<string, string> = {
|
||||
};
|
||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
// Award-column VISIBILITY is governed SOLELY by the awardShown code-set, never
|
||||
// by AG Grid's saved column state: dropping `hide` 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.
|
||||
//
|
||||
// Their POSITION, however, has to stay in the state. Filtering the entries out
|
||||
// entirely left applyColumnState({applyOrder:true}) with no place for them, and
|
||||
// AG Grid appends what the state doesn't mention — so a column the operator
|
||||
// dragged to sit AFTER an award column jumped back in front of it on reload.
|
||||
const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).map((s) => {
|
||||
if (!String(s?.colId ?? '').startsWith('award_')) return s;
|
||||
const { hide: _hide, ...rest } = s as any;
|
||||
return rest;
|
||||
});
|
||||
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, 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]);
|
||||
// Rebuild the columns when the format changes: the formatters are captured
|
||||
// inside the column definitions, so nothing else would notice.
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
// 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.
|
||||
@@ -281,68 +344,221 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// 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]);
|
||||
}, []);
|
||||
|
||||
// Award-column WIDTHS are persisted separately too, for the same reason as
|
||||
// visibility: award columns are stripped from AG Grid's saved column-state
|
||||
// round-trip, so their width would otherwise reset to the default on reopen.
|
||||
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
|
||||
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
|
||||
const awardWidthsRef = useRef<Record<string, number>>({});
|
||||
const awardWidthsInit = useRef(false);
|
||||
if (!awardWidthsInit.current) {
|
||||
awardWidthsInit.current = true;
|
||||
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
|
||||
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||
}
|
||||
}
|
||||
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
|
||||
// apply them to any award columns already on screen.
|
||||
useEffect(() => {
|
||||
if (loadLocal(AWARD_WIDTH_KEY)) return;
|
||||
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
|
||||
if (!remote || !remote.length) return;
|
||||
for (const it of remote as any[]) {
|
||||
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||
}
|
||||
seedLocal(AWARD_WIDTH_KEY, remote);
|
||||
const api = gridRef.current?.api;
|
||||
if (api && awardCols?.length) {
|
||||
const ups = awardCols
|
||||
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
|
||||
.filter((u) => !!u.newWidth);
|
||||
if (ups.length) api.setColumnWidths(ups);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
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,
|
||||
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 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]);
|
||||
}, [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;
|
||||
const widthUps: { key: string; newWidth: number }[] = [];
|
||||
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);
|
||||
// Re-apply the saved width — AG Grid keeps an existing column's width across
|
||||
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
|
||||
const w = awardWidthsRef.current[a.code.toUpperCase()];
|
||||
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
|
||||
}
|
||||
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
// No column filters here, deliberately. This grid holds only the most recent
|
||||
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||
// an empty result then reads as "the log does not contain it" rather than "it
|
||||
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||
//
|
||||
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||
// Removing the filter also gives each header back the width of its menu icon.
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
sortable: !passOrder,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
filter: false,
|
||||
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(sanitizeAwardCols(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(sanitizeAwardCols(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) return;
|
||||
saveState(colStateKey, sanitizeAwardCols(state));
|
||||
// Award columns are stripped above, so persist their widths on the side.
|
||||
let changed = false;
|
||||
for (const s of state) {
|
||||
const id = String((s as any)?.colId ?? '');
|
||||
if (id.startsWith('award_') && (s as any).width) {
|
||||
const code = id.slice('award_'.length).toUpperCase();
|
||||
if (awardWidthsRef.current[code] !== (s as any).width) {
|
||||
awardWidthsRef.current[code] = (s as any).width;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
|
||||
}
|
||||
}, [colStateKey, AWARD_WIDTH_KEY]);
|
||||
|
||||
// The award columns load asynchronously; when they arrive (or change) the
|
||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
||||
// default — wiping the user's saved visibility (award columns reappear,
|
||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
// 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(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const local = loadLocal(colStateKey);
|
||||
if (api && local) api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(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]);
|
||||
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
|
||||
// causes here (awardCols/awardShown) missed the others — switching the UI
|
||||
// language rebuilds the memo through `t`, which set restoringRef and left it
|
||||
// set, so every column change for the rest of the session was silently
|
||||
// discarded and the layout reverted on reload. Any future dependency of the
|
||||
// memo is now covered for free.
|
||||
}, [columnDefs]);
|
||||
|
||||
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));
|
||||
onRowSelectedQso?.(sel[0] ?? null);
|
||||
}
|
||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||
useEffect(() => {
|
||||
@@ -350,15 +566,42 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
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);
|
||||
@@ -386,11 +629,40 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
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={t('rqg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||
@@ -409,12 +681,15 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
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
|
||||
@@ -436,6 +711,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportFiltered={onExportFiltered}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||
@@ -482,8 +758,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
<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={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
||||
<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">
|
||||
|
||||
@@ -24,6 +24,9 @@ interface Props {
|
||||
centerLat?: number | null; // operator latitude (projection centre)
|
||||
centerLon?: number | null; // operator longitude
|
||||
rotorEnabled?: boolean;
|
||||
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||
activeRotor?: number; // index of the selected rotor (0-based)
|
||||
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||
onGoto?: (az: number) => void; // click-to-turn
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
|
||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||
}
|
||||
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
||||
const cardinals = useMemo(
|
||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||
@@ -75,22 +78,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 && (
|
||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||
{rotors && rotors.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||
{rotors.map((nm, i) => {
|
||||
const active = (activeRotor ?? 0) === i;
|
||||
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelectRotor?.(i)}
|
||||
className={cn(
|
||||
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||
active
|
||||
? 'bg-success text-success-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center p-2 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SpellCheck, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type ScpResult = { partial?: string[]; nplus1?: string[] };
|
||||
|
||||
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
|
||||
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
|
||||
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
|
||||
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
|
||||
result: ScpResult;
|
||||
currentCall: string;
|
||||
count: number; // master-list size (0 = list not downloaded yet)
|
||||
onPick: (call: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const cur = (currentCall || '').trim().toUpperCase();
|
||||
const partial = result.partial ?? [];
|
||||
const nplus1 = result.nplus1 ?? [];
|
||||
|
||||
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
|
||||
{calls.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
|
||||
) : calls.map((c) => (
|
||||
<button key={c} type="button" onClick={() => onPick(c)}
|
||||
title={t('scp.fill', { call: c })}
|
||||
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
|
||||
c === cur ? 'bg-success/20 text-success font-bold'
|
||||
: 'hover:bg-primary/15 text-foreground/90')}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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">
|
||||
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
|
||||
{t('scp.noList')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
|
||||
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
|
||||
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +144,7 @@ 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>
|
||||
|
||||
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,735 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, 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 { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
} 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[] };
|
||||
|
||||
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||
// screen; aligned rows are what an operator expects from a dashboard.
|
||||
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||
const GRID_GAP = 16; // px between cards, both axes
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
|
||||
|
||||
// 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)
|
||||
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
|
||||
: (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; rotors?: string[]; active?: 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}
|
||||
rotors={hd.rotors}
|
||||
activeRotor={hd.active}
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||
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');
|
||||
|
||||
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||
// cards as the window allows; an explicit count is honoured as asked, even
|
||||
// past that width — the operator can see his own screen.
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const [colCount, setColCount] = useState(1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||
const el = gridRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [cols]);
|
||||
// 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); };
|
||||
}, []);
|
||||
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
|
||||
// panel still get the controls. Re-read the enabled flag so it appears/hides
|
||||
// without a restart.
|
||||
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||
};
|
||||
load();
|
||||
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
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} />, wide: true });
|
||||
// Tuner Genius XL card (identical to the Flex panel's).
|
||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||
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 && !tgEnabled;
|
||||
|
||||
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', '5', '6'] 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 grid: equal columns, and every card stretches to the height of
|
||||
the tallest one on its row, so the rows line up instead of ending
|
||||
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||
need the room. Each card has a grip handle (left rail) as the drag
|
||||
initiator, since the body is full of buttons. */}
|
||||
<div ref={gridRef} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||
gap: `${GRID_GAP}px`,
|
||||
}}>
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id}
|
||||
// A wide card takes two columns — but never more than there are, or
|
||||
// the grid would grow a phantom column on a narrow window.
|
||||
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
{/* h-full down the chain is what makes the card fill the row height
|
||||
rather than sit at its natural size in a taller cell. */}
|
||||
<div className="flex items-stretch h-full">
|
||||
<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 [&>*]:h-full">{w.node}</div>
|
||||
</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 isDingtian = device.type === 'dingtian';
|
||||
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="dingtian">Dingtian IOT relay (LAN / WiFi)</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 || isDingtian) && (
|
||||
<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] : isDingtian ? [2, 4, 8, 16, 24, 32] : [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 || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : '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 })} />
|
||||
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
|
||||
</div>
|
||||
{/* Dingtian: both are OFF on a factory board — the session ID only when
|
||||
"HTTP Session" is enabled in its web page, the password only when a
|
||||
relay password is set. */}
|
||||
{isDingtian && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtSession')}</Label>
|
||||
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||||
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtPwd')}</Label>
|
||||
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||||
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||||
</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,913 @@
|
||||
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[];
|
||||
// Chronological series across the SELECTED period, one per bucket size; empty
|
||||
// when that bucket would be too fine for the period (see the Go side).
|
||||
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
|
||||
by_hour_of_day: Bucket[]; by_weekday: 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={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
</>
|
||||
)}
|
||||
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||
{data.map((_, i) => (
|
||||
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||
))}
|
||||
</svg>
|
||||
{h >= 0 && (
|
||||
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||
<span>{data[0].key}</span>
|
||||
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||
<span>{data[data.length - 1].key}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||
// donut would hide precisely the fact that makes them interesting).
|
||||
//
|
||||
// The categories ARE the subject here, so this is the one chart using the
|
||||
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||
|
||||
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||
const [hov, setHov] = useState<number | null>(null);
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
|
||||
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||
let acc = 0;
|
||||
const arcs = slots.map((d, i) => {
|
||||
const frac = d.count / total;
|
||||
// A 2px surface gap between segments — the correct separator, not a border.
|
||||
const len = Math.max(0, frac * C - 2);
|
||||
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||
acc += frac;
|
||||
return a;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="relative shrink-0">
|
||||
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||
{arcs.map((a) => (
|
||||
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||
className="transition-[stroke-width] duration-150 cursor-default"
|
||||
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||
))}
|
||||
</svg>
|
||||
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
{hov === null ? (
|
||||
<>
|
||||
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
{slots.map((d, i) => (
|
||||
<li key={d.key}
|
||||
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||
hov === i && 'bg-muted')}>
|
||||
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||
{((d.count / total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||
|
||||
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||
<span className="text-[11px] tabular-nums">
|
||||
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||
<div className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Period ───────────────────────────────────────────────────────────────────
|
||||
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||
// the charts would each show a different slice of time.
|
||||
|
||||
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||
const now = new Date();
|
||||
switch (p) {
|
||||
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||
case 'y12': {
|
||||
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'd30': {
|
||||
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'custom': return [from, to];
|
||||
default: return ['', ''];
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
|
||||
// panel — not a window of its own. It used to be four fixed rolling windows
|
||||
// anchored on the newest QSO, which ignored the period filter entirely: picking
|
||||
// a year up top left this chart showing the last 7 days.
|
||||
const ACT_GRAN = [
|
||||
{ key: 'day', tkey: 'stats.granDay' },
|
||||
{ key: 'week', tkey: 'stats.granWeek' },
|
||||
{ key: 'month', tkey: 'stats.granMonth' },
|
||||
{ key: 'year', tkey: 'stats.granYear' },
|
||||
] as const;
|
||||
type Gran = typeof ACT_GRAN[number]['key'];
|
||||
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
|
||||
|
||||
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||
const [g, setG] = useState<Gran | 'auto'>('auto');
|
||||
const seriesFor = (k: Gran): Bucket[] => (
|
||||
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
|
||||
);
|
||||
|
||||
// Auto picks the finest bucket that stays readable: the backend leaves a
|
||||
// series empty when it would be too fine for the period, so "the first
|
||||
// non-empty one" is exactly the right rule and needs no thresholds here.
|
||||
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
|
||||
// A bucket the user forces but that is too fine for the period falls back to
|
||||
// the next coarser one rather than showing an empty chart.
|
||||
let shown: Gran = g === 'auto' ? auto : g;
|
||||
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
|
||||
const series = seriesFor(shown);
|
||||
const steppedUp = g !== 'auto' && shown !== g;
|
||||
|
||||
return (
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||
<div className="mb-2 flex flex-wrap gap-1 items-center">
|
||||
<button type="button" onClick={() => setG('auto')}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t('stats.granAuto')}
|
||||
</button>
|
||||
{ACT_GRAN.map((o) => (
|
||||
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t(o.tkey)}
|
||||
</button>
|
||||
))}
|
||||
{steppedUp && (
|
||||
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Many buckets read better as a filled trend than as a forest of bars. */}
|
||||
{series.length > 60
|
||||
? <AreaTrend data={series} empty={empty} />
|
||||
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// RhythmCard — WHEN the operator is on the air over the selected period, which is
|
||||
// a different question from "how much lately" and so gets its own card. The hour
|
||||
// histogram used to cover a single day, too little to read anything from.
|
||||
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||
const [g, setG] = useState<'hour' | 'weekday'>('hour');
|
||||
return (
|
||||
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
|
||||
<button key={k} type="button" onClick={() => setG(k)}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t(tk)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||
|
||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{data.map((d) => (
|
||||
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function StatsPanel() {
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||
const [period, setPeriod] = useState<Period>('all');
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||
// that contest AND lets the window derive from its own span — no date typing.
|
||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||
const [contest, setContest] = useState('');
|
||||
// Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
|
||||
const [operators, setOperators] = useState<string[]>([]);
|
||||
const [operator, setOperator] = useState('');
|
||||
|
||||
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||
useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);
|
||||
|
||||
const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
|
||||
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||
// with it — the backend derives them. Sending a period as well would be two
|
||||
// filters fighting over the same axis.
|
||||
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
|
||||
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||
// once, here, rather than guarding at every use site and missing one.
|
||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||
setStats({
|
||||
...raw,
|
||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
|
||||
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
|
||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||
} as Stats);
|
||||
}
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||
useEffect(() => {
|
||||
if (!contest && period === 'custom' && !(from && to)) return;
|
||||
load(period, from, to, contest, operator);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [period, from, to, contest, operator]);
|
||||
|
||||
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||
// between two ordinary evenings — numbers that are true and completely useless.
|
||||
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||
// real operating session (which is exactly when an hourly rate chart exists).
|
||||
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||
|
||||
const span = useMemo(() => {
|
||||
if (!stats?.first_qso) return '';
|
||||
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||
}, [stats]);
|
||||
|
||||
if (busy && !stats) {
|
||||
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||
</div>;
|
||||
}
|
||||
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||
if (!stats) return null;
|
||||
|
||||
const empty = t('stats.noData');
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||
|
||||
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||
never from a static list — so it can't offer a contest you never entered.
|
||||
Choosing one supersedes the period: it brings its own window. */}
|
||||
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||
<option value="">{t('stats.noContest')}</option>
|
||||
{runs.map((r) => (
|
||||
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||
{r.id} {r.year} — {nf(r.count)} QSO
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Operator picker — narrows every stat (totals, DXCC, top countries,
|
||||
continent split, rate) to one operator from the log. "—" = the
|
||||
station owner (QSOs logged with no OPERATOR set). */}
|
||||
{operators.length > 1 && (
|
||||
<select value={operator} onChange={(e) => setOperator(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
|
||||
title={t('stats.operatorTip')}>
|
||||
<option value="">{t('stats.allOperators')}</option>
|
||||
{operators.map((op) => (
|
||||
<option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||
{([
|
||||
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||
] as [Period, string][]).map(([p, label], i) => (
|
||||
<button key={p} onClick={() => setPeriod(p)}
|
||||
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!contest && period === 'custom' && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<button onClick={() => setView('charts')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||
</button>
|
||||
<button onClick={() => setView('table')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<Table2 className="size-3.5" />{t('stats.table')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||
</div>
|
||||
|
||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||
{windowed && stats.total > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||
<div title={t('stats.avgWindowTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.avgActiveTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.best60Tip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||
</div>
|
||||
{/* On-air + off-air = the window, by construction. The first version
|
||||
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||
<div title={t('stats.onAirTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||
{dur(stats.on_air_minutes)}
|
||||
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.rate.length > 1
|
||||
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||||
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||
{stats.gaps.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 min-w-0">
|
||||
{stats.gaps.map((g, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||
<span className="tabular-nums text-muted-foreground truncate">
|
||||
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||
</span>
|
||||
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||
style={{
|
||||
background: 'var(--chart-seq-1)',
|
||||
}}>
|
||||
<span className="block h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||
background: 'var(--chart-1)',
|
||||
}} />
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
|
||||
by operator. That is a table's job, not a chart's — and it's how
|
||||
contesters actually read a run. Every value here is also what the
|
||||
stacked chart above draws, so the two can never disagree. */}
|
||||
{stats.rate.length > 1 && (
|
||||
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||||
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'table' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
|
||||
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||
</Card>
|
||||
|
||||
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||
<RhythmCard stats={stats} t={t} empty={empty} />
|
||||
|
||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||
instead of truncating. */}
|
||||
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
|
||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||
<Donut data={stats.by_continent} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byStation')} accent="var(--chart-8)">
|
||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
||||
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
|
||||
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
|
||||
// mirrors the native app's two channels (A / B) with their source, frequency and
|
||||
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
||||
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
||||
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
|
||||
// so they're the exact same size as the Flex/amp meters.
|
||||
|
||||
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<button type="button" onClick={toggle}
|
||||
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||
</button>
|
||||
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
|
||||
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
|
||||
// antenna, matching the two rows of the native 4O3A app.
|
||||
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
|
||||
onSelect: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const cls = ptt
|
||||
? '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)]'
|
||||
: active
|
||||
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-card text-foreground/80 border-border hover:bg-muted';
|
||||
const src = ch.mode_str || '—';
|
||||
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
|
||||
return (
|
||||
<button type="button" onClick={onSelect}
|
||||
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-extrabold text-sm">{letter}</span>
|
||||
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
|
||||
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
|
||||
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
|
||||
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
|
||||
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
|
||||
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
|
||||
// between syllables as often as on a peak, so the raw reading collapses to zero
|
||||
// several times a second mid-transmission and reads as a dropped link. The
|
||||
// amplifier looked steady next to it only because it already smoothed this way.
|
||||
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
|
||||
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 > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
const connected = !!status.connected;
|
||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||
// below still smooths what is left between polls.
|
||||
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||
const fwdW = peakHold('fwd', rawFwdW);
|
||||
|
||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||
// meaningless 1.00, which would wipe the figure the operator actually wants —
|
||||
// the one measured while transmitting. So show the live value during TX, keep it
|
||||
// briefly afterwards so it can be read, then let it go.
|
||||
//
|
||||
// That expiry is the point: the first version of this held the last TX value
|
||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||
// Same one-second window as the power peak above, so the two meters clear
|
||||
// together.
|
||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||
const heldSwr = swrHold.current;
|
||||
const vswr = rawFwdW >= 1
|
||||
? rawVswr
|
||||
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||
? heldSwr.v
|
||||
: undefined;
|
||||
const active = status.active ?? 1;
|
||||
const a: TGChannel = status.a ?? {};
|
||||
const b: TGChannel = status.b ?? {};
|
||||
|
||||
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
|
||||
return (
|
||||
<Card icon={Gauge} title={title} accent="#f59e0b">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.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')}>
|
||||
{status.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusAutotune().catch(() => {})}
|
||||
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
|
||||
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||
</button>
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
|
||||
{t('tgp.bypass')}
|
||||
</button>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
|
||||
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{status.message && (
|
||||
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold">⚠ {status.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connected && (
|
||||
<>
|
||||
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
|
||||
<div className="flex items-stretch gap-2">
|
||||
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
|
||||
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
|
||||
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
|
||||
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||
</div>
|
||||
|
||||
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
|
||||
3-wide because it shows three meters; copying it here left the tuner's
|
||||
two meters in the first two of three columns, with a third of the card
|
||||
empty to the right of SWR. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
|
||||
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Gauge, X, Power, Radio } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type TGChannel = {
|
||||
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
|
||||
freq_mhz?: number; bypass?: boolean; antenna?: number;
|
||||
};
|
||||
export type TGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||
antenna?: number; three_way?: boolean; message?: string;
|
||||
a?: TGChannel; b?: TGChannel;
|
||||
relay_c1?: number; relay_l?: number; relay_c2?: number;
|
||||
};
|
||||
|
||||
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
|
||||
// red above (the usual "safe / caution / high" ATU thresholds).
|
||||
function swrColour(vswr?: number): string {
|
||||
if (!vswr || vswr <= 0) return 'text-muted-foreground';
|
||||
if (vswr <= 1.5) return 'text-success';
|
||||
if (vswr <= 2.0) return 'text-warning';
|
||||
return 'text-danger';
|
||||
}
|
||||
|
||||
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
|
||||
// active channel (green) or TX (red), shows the source + frequency + antenna,
|
||||
// and clicking it makes that channel active.
|
||||
function ChanRow({ letter, ch, active, onSelect, t }: {
|
||||
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
|
||||
t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const ptt = !!ch.ptt;
|
||||
const cls = ptt
|
||||
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
|
||||
: active
|
||||
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
|
||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||
const src = ch.mode_str || '—';
|
||||
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
|
||||
return (
|
||||
<button type="button" onClick={onSelect}
|
||||
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
|
||||
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
|
||||
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
|
||||
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
|
||||
{ch.antenna != null && ch.antenna > 0 && (
|
||||
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
|
||||
)}
|
||||
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
|
||||
// the live SWR / forward power, the two RF channels (A / B, click to activate),
|
||||
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
|
||||
// panel and Station Control.
|
||||
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
|
||||
status: TGStatus;
|
||||
onTune: () => void;
|
||||
onBypass: (on: boolean) => void;
|
||||
onOperate: (on: boolean) => void;
|
||||
onActivate: (ch: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const active = status.active ?? 1;
|
||||
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||
// between syllables as often as on a peak, so the plain figure showed a few
|
||||
// hundred watts on a kilowatt transmission.
|
||||
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
|
||||
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">
|
||||
<Gauge 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">Tuner Genius</span>
|
||||
<span className="flex-1" />
|
||||
<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('tgp.online') : t('tgp.offline')}</span>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!status.connected ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
|
||||
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
|
||||
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
|
||||
{/* SWR + forward power readouts */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
|
||||
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
|
||||
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel A / B — click to activate */}
|
||||
<div className="space-y-1">
|
||||
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
|
||||
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
|
||||
</div>
|
||||
|
||||
{status.message && (
|
||||
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tune */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTune}
|
||||
disabled={status.tuning}
|
||||
title={t('tgp.tuneHint')}
|
||||
className={cn(
|
||||
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
|
||||
status.tuning
|
||||
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
|
||||
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<Radio className="size-4" />
|
||||
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Bypass + Operate toggles */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBypass(!status.bypass)}
|
||||
title={t('tgp.bypassHint')}
|
||||
className={cn(
|
||||
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||
status.bypass
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{t('tgp.bypass')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOperate(!status.operate)}
|
||||
title={t('tgp.operateHint')}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||
status.operate
|
||||
? '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-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Power className="size-3.5" />
|
||||
{status.operate ? t('tgp.operate') : t('tgp.standby')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ type UDPConfig = {
|
||||
direction: 'inbound' | 'outbound';
|
||||
name: string;
|
||||
port: number;
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated';
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo';
|
||||
multicast: boolean;
|
||||
multicast_group: string;
|
||||
destination_ip: string;
|
||||
@@ -77,6 +77,20 @@ const SERVICE_TYPES: Array<{
|
||||
hint: 'udpp.svcDbHint',
|
||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'pstrotator_freq',
|
||||
direction: 'outbound',
|
||||
label: 'udpp.svcPstLabel',
|
||||
hint: 'udpp.svcPstHint',
|
||||
defaults: { port: 12040, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'n1mm_radioinfo',
|
||||
direction: 'outbound',
|
||||
label: 'udpp.svcN1mmRadioLabel',
|
||||
hint: 'udpp.svcN1mmRadioHint',
|
||||
defaults: { port: 12060, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
];
|
||||
|
||||
type Props = { onError: (msg: string) => void };
|
||||
@@ -307,7 +321,6 @@ function EditDialog({
|
||||
<Label>{t('udpp.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Upload, FolderOpen, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns,
|
||||
TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
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 { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Cfg = {
|
||||
enabled: boolean; format: string; folder: string; file_name: string; title: string;
|
||||
count: number; interval_min: number; columns: string[];
|
||||
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
|
||||
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
|
||||
};
|
||||
type Col = { key: string; header: string };
|
||||
|
||||
export function WebPublishPanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<Cfg | null>(null);
|
||||
const [cols, setCols] = useState<Col[]>([]);
|
||||
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
// Raw text for the two numeric boxes: bound straight to the number they could
|
||||
// not be cleared, the same trap as the Recent-QSOs Max field.
|
||||
const [countText, setCountText] = useState('');
|
||||
const [everyText, setEveryText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]);
|
||||
setCfg(c as any);
|
||||
setCols((k ?? []) as Col[]);
|
||||
setCountText(String((c as any).count ?? 100));
|
||||
setEveryText(String((c as any).interval_min ?? 0));
|
||||
const s: any = st;
|
||||
if (s?.last_err) setErr(s.last_err);
|
||||
else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!cfg) return <p className="text-xs text-muted-foreground">…</p>;
|
||||
const set = (p: Partial<Cfg>) => setCfg((c) => (c ? { ...c, ...p } : c));
|
||||
|
||||
// Every control saves immediately: this panel has no Save button of its own,
|
||||
// matching the other "saved instantly" panels.
|
||||
const save = async (next: Cfg) => {
|
||||
setCfg(next);
|
||||
try { await SaveWebPublishConfig(next as any); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
};
|
||||
const patch = (p: Partial<Cfg>) => save({ ...cfg, ...p });
|
||||
|
||||
const toggleCol = (key: string) => {
|
||||
const has = cfg.columns?.includes(key);
|
||||
patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] });
|
||||
};
|
||||
|
||||
const run = async (what: 'test' | 'publish') => {
|
||||
setBusy(what); setMsg(''); setErr('');
|
||||
try {
|
||||
const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow();
|
||||
setMsg(String(r));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setBusy(''); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
|
||||
{t('wpub.enable')}
|
||||
</label>
|
||||
|
||||
{cfg.enabled && (<>
|
||||
{/* ── The file ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">{t('wpub.format')}</Label>
|
||||
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
|
||||
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
|
||||
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Label className="text-sm">{t('wpub.folder')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
|
||||
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
|
||||
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
|
||||
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
|
||||
</div>
|
||||
|
||||
<Label className="text-sm">{t('wpub.fileName')}</Label>
|
||||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
|
||||
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.title')}</Label>
|
||||
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
|
||||
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.count')}</Label>
|
||||
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
|
||||
onChange={(e) => setCountText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const n = Math.floor(Number(countText));
|
||||
if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count));
|
||||
}} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.every')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
|
||||
onChange={(e) => setEveryText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const n = Math.floor(Number(everyText));
|
||||
if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min));
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Columns ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cols.map((c) => {
|
||||
const on = cfg.columns?.includes(c.key);
|
||||
return (
|
||||
<button key={c.key} type="button" onClick={() => toggleCol(c.key)}
|
||||
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
||||
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{c.header}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Upload ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
|
||||
{t('wpub.ftpEnable')}
|
||||
</label>
|
||||
{cfg.ftp_enabled && (
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
|
||||
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
|
||||
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
|
||||
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
|
||||
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
|
||||
</div>
|
||||
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
|
||||
<Input className="h-8" value={cfg.ftp_user}
|
||||
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
|
||||
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
|
||||
<Input type="password" className="h-8" value={cfg.ftp_password}
|
||||
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
|
||||
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
|
||||
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
|
||||
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
|
||||
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
|
||||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
|
||||
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
|
||||
<span />
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
|
||||
{t('wpub.ftpTls')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
|
||||
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
|
||||
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
||||
{t('wpub.publishNow')}
|
||||
</Button>
|
||||
{cfg.ftp_enabled && (
|
||||
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
|
||||
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{t('wpub.testFtp')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{msg && <p className="text-xs text-success break-all">{msg}</p>}
|
||||
{err && <p className="text-xs text-danger break-all">{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,9 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
onRefreshPorts: () => void;
|
||||
onConnect: () => void;
|
||||
@@ -49,7 +52,7 @@ interface Props {
|
||||
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
||||
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
||||
export function WinkeyerPanel({
|
||||
status, ports, port, wpm, macros, sent,
|
||||
status, ports, port, wpm, macros, sent, source, breakIn = 0, onSetBreakIn,
|
||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||
onSend, onSendMacro, onStop, onClose,
|
||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||
@@ -96,11 +99,22 @@ export function WinkeyerPanel({
|
||||
{/* Header / connection bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{!connected ? (
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: source === 'yaesu' || source === 'kenwood'
|
||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
|
||||
@@ -132,7 +146,7 @@ export function WinkeyerPanel({
|
||||
<Label className="text-xs w-8 shrink-0">TX</Label>
|
||||
<div className={cn(
|
||||
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
||||
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
|
||||
status.busy ? 'text-success' : 'text-muted-foreground',
|
||||
)}>
|
||||
{sent || <span className="opacity-50">—</span>}
|
||||
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||
@@ -150,17 +164,37 @@ export function WinkeyerPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Break-in (Icom only): the rig's 0x17 keyer only transmits when break-in
|
||||
is on — SEMI or FULL. OFF keys the sidetone but stays in RX. */}
|
||||
{source === 'icom' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-16 shrink-0" title={t('wkp.breakInHint')}>{t('wkp.breakIn')}</Label>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{[{ v: 0, l: t('wkp.bkOff') }, { v: 1, l: 'SEMI' }, { v: 2, l: 'FULL' }].map((o) => (
|
||||
<button key={o.v} type="button" disabled={!connected} onClick={() => onSetBreakIn?.(o.v)}
|
||||
className={cn('px-2.5 py-1 text-[11px] font-bold tracking-wide border-l border-border first:border-l-0 transition-colors disabled:opacity-40',
|
||||
breakIn === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{breakIn === 0 && <span className="text-[10px] text-warning">{t('wkp.bkOffWarn')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CW text */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
{t('wkp.cwText')}
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||
{t('wkp.sendOnType')}
|
||||
</label>
|
||||
{(source === 'winkeyer' || source === 'flex') && (
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||
{t('wkp.sendOnType')}
|
||||
</label>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
value={cwText}
|
||||
@@ -195,12 +229,17 @@ export function WinkeyerPanel({
|
||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||
</div>
|
||||
{autoCall && <span className="text-[10px] text-amber-600/80">{t('wkp.loopHint')}</span>}
|
||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short.
|
||||
Empty macros (no label AND no text) are hidden, like the voice keyer;
|
||||
the F-number stays tied to the macro's real index so shortcuts match. */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{macros.map((m, i) => (
|
||||
{macros
|
||||
.map((m, i) => ({ m, i }))
|
||||
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
|
||||
.map(({ m, i }) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
@@ -217,7 +256,7 @@ export function WinkeyerPanel({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
|
||||
{status.error && <div className="text-[11px] text-danger">{status.error}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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, Star } from 'lucide-react';
|
||||
import {
|
||||
@@ -13,37 +14,20 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||
|
||||
type Props = {
|
||||
// Operator's CURRENT locator — fallback for the distance column (see catalog).
|
||||
myGrid?: string;
|
||||
wb: WorkedBeforeView | null;
|
||||
busy: boolean;
|
||||
currentCall: string;
|
||||
@@ -54,6 +38,10 @@ 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;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award (cell = the reference this QSO counts for).
|
||||
awardCols?: { code: string; name: string }[];
|
||||
@@ -69,14 +57,16 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
@@ -126,15 +116,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
// Restore AFTER the profile scope is known: this grid has no key= remount to
|
||||
// save it from reading the wrong (unscoped) cache key at first paint, so it
|
||||
// used to miss the cache every time and then save under the scoped key.
|
||||
async function onGridReady(e: GridReadyEvent) {
|
||||
await whenGridPrefsReady();
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
const remote = await loadRemote(COL_STATE_KEY);
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||
@@ -150,7 +143,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
// columnDefs itself, not the reasons it was rebuilt — see the same note in
|
||||
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
|
||||
}, [columnDefs]);
|
||||
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
@@ -266,6 +261,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
|
||||
mean "the whole logbook under the active column filters", but this grid
|
||||
is a per-callsign view rather than a filter over the log, so the entry
|
||||
would quietly export everything — not what it would appear to do here.
|
||||
The selection-based exports and bulk edit apply unchanged. */}
|
||||
<QSOContextMenu
|
||||
menu={menu}
|
||||
onClose={() => setMenu(null)}
|
||||
@@ -275,6 +275,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
||||
import {
|
||||
GetYaesuState, RefreshYaesuPanel,
|
||||
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
|
||||
type YaesuState = {
|
||||
available: boolean; model?: string; mode?: string; raw_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; squelch: number;
|
||||
agc?: string; preamp: number; att: number;
|
||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||
};
|
||||
|
||||
const ZERO: YaesuState = {
|
||||
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, squelch: 0,
|
||||
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
};
|
||||
|
||||
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
||||
// the radio's own band keys do. That is why these are band names, not Hz.
|
||||
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||
|
||||
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
|
||||
// the operator is the one who knows which they want, so each shows its sideband
|
||||
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
|
||||
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
|
||||
// the band plan dictates, and AM/FM have none.
|
||||
//
|
||||
// PSK rides on the rig's DATA mode, like the other digital modes — the button
|
||||
// exists because the operator thinks in modes, not in what the radio calls them.
|
||||
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
|
||||
const MODES: ModeBtn[] = [
|
||||
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
|
||||
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
|
||||
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
|
||||
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
|
||||
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
|
||||
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
|
||||
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
|
||||
];
|
||||
|
||||
// Which button the rig's current raw mode belongs to, and on which sideband.
|
||||
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||
switch ((raw || '').toUpperCase()) {
|
||||
case 'USB': return { id: 'SSB', side: 'U' };
|
||||
case 'LSB': return { id: 'SSB', side: 'L' };
|
||||
case 'CW-U': return { id: 'CW', side: 'U' };
|
||||
case 'CW-L': return { id: 'CW', side: 'L' };
|
||||
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
|
||||
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
|
||||
case 'DATA-U': return { id: 'DIGI', side: 'U' };
|
||||
case 'DATA-L': return { id: 'DIGI', side: 'L' };
|
||||
case 'AM': return { id: 'AM', side: 'U' };
|
||||
case 'FM': return { id: 'FM', side: 'U' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
|
||||
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function bandOfHz(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '';
|
||||
const mhz = hz / 1_000_000;
|
||||
const bands: [string, number, number][] = [
|
||||
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
|
||||
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
|
||||
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
|
||||
];
|
||||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
// Split the 0-100 S-meter reading into S units + dB over S9.
|
||||
//
|
||||
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
|
||||
// falls; the front panel puts it at roughly half travel, which is the 50 used
|
||||
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
|
||||
// unit off on a radio, this is the number to correct, not the RST helper.
|
||||
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
|
||||
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
|
||||
|
||||
function sParts(v: number): { s: number; over: number; label: string } {
|
||||
if (v >= S9_PCT) {
|
||||
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
|
||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
|
||||
// through the S9+ range, red once the signal is strong enough to be reported as
|
||||
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
|
||||
// change always lands exactly where the numbers say it should — if the S9 point
|
||||
// is ever corrected, the colours follow on their own.
|
||||
const RED_OVER_DB = 20;
|
||||
function sSegColor(frac: number): string {
|
||||
const pct = frac * 100;
|
||||
if (pct < S9_PCT) return '#16a34a';
|
||||
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
|
||||
return '#dc2626';
|
||||
}
|
||||
|
||||
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
|
||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
|
||||
}) {
|
||||
const v = Math.max(min, Math.min(max, value));
|
||||
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
// React's onWheel is passive, so preventDefault is ignored there — attach a
|
||||
// native non-passive listener, and read live values through refs so the
|
||||
// handler never closes over a stale value.
|
||||
const valRef = useRef(value); valRef.current = value;
|
||||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||
const disRef = useRef(disabled); disRef.current = disabled;
|
||||
const minRef = useRef(min); minRef.current = min;
|
||||
const maxRef = useRef(max); maxRef.current = max;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
e.preventDefault();
|
||||
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
||||
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={min} max={max} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
|
||||
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
|
||||
// The filled side was invisible against the dark theme: --muted is barely
|
||||
// lighter than the card it sits on, so the whole track read as one bar.
|
||||
// An explicit translucent track keeps both halves distinct in either theme.
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
|
||||
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, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} title={title}
|
||||
className={cn('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 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||
onReportRST?: (rst: string) => void;
|
||||
// Told whenever the operator moves the CW speed here, so the app can keep the
|
||||
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
|
||||
// the timing and the rig's internal keyer speed changes nothing audible — the
|
||||
// slider looked broken because it was driving the wrong keyer.
|
||||
onKeySpeed?: (wpm: number) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<YaesuState>(ZERO);
|
||||
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
|
||||
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
|
||||
// main display showed the operator the frequency they transmit on and an
|
||||
// offset of 0 kHz against itself.
|
||||
const [freqHz, setFreqHz] = useState(0);
|
||||
const [txHz, setTxHz] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
// Optimistic local values for the sliders. Without them a drag fights the
|
||||
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
|
||||
const [local, setLocal] = useState<Partial<YaesuState>>({});
|
||||
const localAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = (await GetYaesuState()) as YaesuState;
|
||||
const c = await GetCATState();
|
||||
if (!alive) return;
|
||||
setSt(s);
|
||||
const cs = c as any;
|
||||
const tx = cs?.freq_hz ?? 0;
|
||||
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
|
||||
setFreqHz(rx);
|
||||
setTxHz(tx);
|
||||
// Drop the optimistic overlay once the rig has had time to answer with
|
||||
// the new value — 1.2 s covers the slow-beat settings read.
|
||||
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||
setErr('');
|
||||
} catch (e: any) {
|
||||
if (alive) setErr(String(e?.message ?? e));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 400);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const view = { ...st, ...local };
|
||||
|
||||
// Every setter follows the same shape: show the value at once, remember when,
|
||||
// and let the poll take over. A rejected command surfaces as an error rather
|
||||
// than as a control that silently springs back.
|
||||
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
|
||||
setLocal((l) => ({ ...l, [key]: value }));
|
||||
localAtRef.current = Date.now();
|
||||
fn().catch((e) => setErr(String(e?.message ?? e)));
|
||||
}
|
||||
|
||||
const band = bandOfHz(freqHz);
|
||||
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
|
||||
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
|
||||
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
|
||||
|
||||
if (!st.available) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center p-6 text-center">
|
||||
<div className="space-y-1">
|
||||
<Radio className="size-8 mx-auto text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
|
||||
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
|
||||
it without mx-auto left the console pinned to the left edge with a
|
||||
window of empty space beside it. */}
|
||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||
{/* VFO + status */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
|
||||
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
|
||||
{view.split && txHz > 0 && (
|
||||
<div className="text-[11px] font-mono tabular-nums text-warning">
|
||||
{t('yaesu.txOn')} {fmtVFO(txHz)}
|
||||
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{st.transmitting && (
|
||||
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
|
||||
)}
|
||||
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
|
||||
{/* The usual pile-up offsets. Which one is idiomatic depends on the
|
||||
mode — up 5 on phone, up 1 on CW — so both are offered rather than
|
||||
guessed, and each turns split on in the same action. */}
|
||||
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.splitUpHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
|
||||
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.splitUpHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
|
||||
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.tuneHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
TUNE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-xs text-destructive px-1">{err}</p>}
|
||||
|
||||
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
|
||||
consoles read alike instead of each having its own instrument style. */}
|
||||
<Card icon={Activity} title={t('yaesu.meters')}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
|
||||
display={sParts(view.s_meter).label}
|
||||
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
||||
title={onReportRST ? t('yaesu.sToRst') : undefined} />
|
||||
{/* Watts as MEASURED, not the power setting scaled by a percentage:
|
||||
the setting says what was asked for, the meter says what left. */}
|
||||
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
|
||||
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
|
||||
{/* The RATIO, as the rig shows it — a percentage of meter travel is
|
||||
not something an operator can act on. The bar keeps the travel. */}
|
||||
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
|
||||
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bands + modes */}
|
||||
<Card icon={Antenna} title={t('yaesu.bandMode')}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{BANDS.map((b) => (
|
||||
<button key={b} type="button"
|
||||
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{b.replace('m', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{MODES.map((m) => {
|
||||
const act = activeMode(view.raw_mode);
|
||||
const on = act?.id === m.id;
|
||||
// The sideband shown is the rig's when this mode is active, else the
|
||||
// one the band plan implies — so a button says what pressing it will
|
||||
// actually do rather than a stale letter.
|
||||
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
|
||||
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
|
||||
return (
|
||||
<button key={m.id} type="button"
|
||||
onClick={() => {
|
||||
// Already on this mode → the click means "the other sideband".
|
||||
const target = m.id === 'SSB'
|
||||
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
|
||||
: m.rig(on && m.sideband ? flip : side);
|
||||
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
|
||||
}}
|
||||
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
|
||||
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{m.sideband ? m.label + '-' + side : m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Receive */}
|
||||
<Card icon={AudioLines} title={t('yaesu.receive')}>
|
||||
<Row label="AF">
|
||||
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
|
||||
</Row>
|
||||
<Row label="RF">
|
||||
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
|
||||
</Row>
|
||||
<Row label="SQL">
|
||||
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
|
||||
</Row>
|
||||
<Row label="AGC">
|
||||
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
|
||||
</Row>
|
||||
<Row label="FRONT">
|
||||
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
<Row label="ATT">
|
||||
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Noise + filter */}
|
||||
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
||||
</div>
|
||||
<Row label="DNR">
|
||||
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||
the radio's own display counts 1-15, and matching it is what makes
|
||||
the panel readable next to the front panel. */}
|
||||
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
|
||||
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Transmit */}
|
||||
<Card icon={Mic} title={t('yaesu.transmit')}>
|
||||
<Row label="PWR">
|
||||
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||
percentage would be a second unit to reconcile every time. */}
|
||||
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
||||
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
||||
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||
</Row>
|
||||
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
|
||||
— so they are hidden rather than shown as dead controls. */}
|
||||
{!isCW && (
|
||||
<>
|
||||
<Row label="MIC">
|
||||
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
|
||||
</Row>
|
||||
<Row label="VOX">
|
||||
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="">
|
||||
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
{t('yaesu.refresh')}
|
||||
</button>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* CW — only in CW, where these replace the phone controls above. */}
|
||||
{isCW && (
|
||||
<Card icon={Radio} title={t('yaesu.cw')}>
|
||||
<Row label="SPEED">
|
||||
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
|
||||
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
|
||||
</Row>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
|
||||
label="BK-IN" title={t('yaesu.breakInHint')} />
|
||||
{/* ZIN is a one-shot: the rig retunes so the station being received
|
||||
lands on the operator's own CW pitch. Not a toggle, so it is a
|
||||
plain button rather than a chip that would look latched. */}
|
||||
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.zinHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
ZIN
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
|
||||
|
||||
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
||||
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
|
||||
const avail = box.w - 56;
|
||||
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||
let cursor = 28;
|
||||
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
|
||||
return (
|
||||
<>
|
||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
|
||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{box.title}
|
||||
</text>
|
||||
{cols.map(({ f, x }) => (
|
||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
||||
<text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||
</text>
|
||||
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
|
||||
<text y={26} fontSize={28} fontWeight={700} fill={fg}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{values[f] ?? ''}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{box.footer && (
|
||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
|
||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||
{box.footer}
|
||||
</text>
|
||||
|
||||
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
||||
onChange={(w) => onPatchBox({ w })} />
|
||||
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||
onChange={(h) => onPatchBox({ h })} />
|
||||
<NumberField label="Opacity %" value={Math.round((box.bg_opacity ?? 1) * 100)} min={0} max={100}
|
||||
onChange={(v) => onPatchBox({ bg_opacity: Math.max(0, Math.min(100, v)) / 100 })} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Background</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(box.bg ?? '') ? box.bg : '#000000'}
|
||||
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||
<Input className="h-7 w-32 font-mono text-xs" value={box.bg ?? ''}
|
||||
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Text color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(box.fg ?? '') ? (box.fg as string) : '#14243a'}
|
||||
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||
<Input className="h-7 w-32 font-mono text-xs" value={box.fg ?? ''} placeholder="#14243a"
|
||||
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<NumberField label="Corner radius" value={box.radius ?? 0} min={0} max={80}
|
||||
onChange={(radius) => onPatchBox({ radius })} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Title</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||
|
||||
@@ -233,7 +233,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
<DialogContent className="max-w-[1260px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-amber-500" />
|
||||
<Sparkles className="size-5 text-warning" />
|
||||
QSL card designer
|
||||
{view !== 'home' && (
|
||||
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
||||
@@ -246,7 +246,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
|
||||
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
||||
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||
)}
|
||||
|
||||
{view === 'home' && (
|
||||
@@ -284,7 +284,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
|
||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-warning text-warning" />}
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-0.5">
|
||||
@@ -299,7 +299,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm"
|
||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
|
||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-destructive' : 'w-7'}`}
|
||||
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||
</Button>
|
||||
@@ -323,7 +323,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
{proposals.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
|
||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-info"
|
||||
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||
>
|
||||
<CardPreview model={p.model} assets={p.assets} width={352} />
|
||||
|
||||
@@ -75,13 +75,13 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||
<DialogContent className="max-w-[820px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
|
||||
<Mail className="size-5 text-danger" /> Send OpsLog QSL by e-mail
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
||||
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||
)}
|
||||
|
||||
{open && !templateId && !error && (
|
||||
@@ -107,7 +107,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||
|
||||
<DialogFooter>
|
||||
{sent ? (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface QSOBox {
|
||||
h: number;
|
||||
bg: string;
|
||||
bg_opacity: number;
|
||||
fg?: string; // text colour (default dark); set it when using a dark background
|
||||
radius: number;
|
||||
title: string;
|
||||
fields: string[];
|
||||
|
||||
@@ -64,7 +64,17 @@ export function Combobox({
|
||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||
// open the list (so tabbing in doesn't pop the dropdown).
|
||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setQuery(v);
|
||||
setOpen(true);
|
||||
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||
// without leaving the field uses what was just typed). With free text that's
|
||||
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||
// that's actually in the list, so a half-typed or invalid report never
|
||||
// becomes the committed value — blur then reverts the leftover text.
|
||||
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
|
||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
// No focus ring on the panel itself. Radix makes the content focusable and
|
||||
// moves focus into it when a tab is selected, so clicking anywhere in the
|
||||
// lower half drew a full-width orange rule under the tab strip. The panel is
|
||||
// a container — what it holds carries its own focus styling.
|
||||
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -14,7 +14,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-stone-900 px-2.5 py-1.5 text-xs text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
|
||||
// text field — the inverse of appendTokens. Used when a picked reference is
|
||||
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
|
||||
// otherwise the ref is re-derived from the field and reappears on reopen.
|
||||
function removeTokens(existing: string | undefined, refs: string): string {
|
||||
let out = existing ?? '';
|
||||
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
return out.replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||
// The award engine honours these regardless of how the award matches, so a
|
||||
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
||||
// data lives in its conventional place and exports correctly. Awards that match
|
||||
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||
// on the override — we don't pollute the text field with a code.
|
||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
|
||||
const byCode = parseAwardRefs(awardRefs);
|
||||
// References the operator REMOVED since the editor opened: for in-field awards
|
||||
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
|
||||
// ref doesn't get re-derived from the leftover token and reappear on reopen.
|
||||
if (prevAwardRefs != null) {
|
||||
const prev = parseAwardRefs(prevAwardRefs);
|
||||
for (const [code, prevRef] of Object.entries(prev)) {
|
||||
const field = fieldOf[code] || code.toLowerCase();
|
||||
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
|
||||
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
|
||||
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
|
||||
if (removed.length === 0) continue;
|
||||
const rm = removed.join(',');
|
||||
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
|
||||
else payload.notes = removeTokens(payload.notes, rm);
|
||||
}
|
||||
}
|
||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||
const overrides: string[] = [];
|
||||
for (const [code, ref] of Object.entries(byCode)) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||
//
|
||||
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||
// upload. This is the reading layer only, because an operator reading
|
||||
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||
// and that is a display problem, not a data one.
|
||||
//
|
||||
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||
// preference would become unreadable the day the preference changed.
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||
|
||||
const KEY = 'opslog.dateFormat';
|
||||
|
||||
function read(): DateFormat {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||
}
|
||||
|
||||
let current: DateFormat = read();
|
||||
|
||||
// A minimal store so every grid and panel re-renders the moment the format
|
||||
// changes, rather than showing the old one until the next restart.
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getDateFormat(): DateFormat {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setDateFormat(f: DateFormat): void {
|
||||
current = f;
|
||||
writeUiPref(KEY, f);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeDateFormat(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||
// have been synced from the database at startup, which happens AFTER this
|
||||
// module's first read.
|
||||
export function reloadDateFormat(): void {
|
||||
const v = read();
|
||||
if (v !== current) {
|
||||
current = v;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||
// in the French one would be conventional but it also invites the reader to
|
||||
// take it for a US date, which is exactly the confusion being fixed.
|
||||
function order(y: number, m: number, d: number): string {
|
||||
switch (current) {
|
||||
case 'fr':
|
||||
return `${pad(d)}-${pad(m)}-${y}`;
|
||||
case 'us':
|
||||
return `${pad(m)}-${pad(d)}-${y}`;
|
||||
}
|
||||
return `${y}-${pad(m)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||
//
|
||||
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||
// twice a year.
|
||||
export function formatDateTimeUTC(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(String(s));
|
||||
if (isNaN(d.getTime())) return String(s);
|
||||
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||
export function formatDateOnly(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Grey line — the day/night terminator, and the twilight band around it where
|
||||
// HF propagation briefly does things it does at no other time.
|
||||
//
|
||||
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||
// this has to work in a field shack with no internet.
|
||||
//
|
||||
// Everything here is pure so it can be tested against known positions; the
|
||||
// drawing lives in the map component.
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// julianDay for a date, including the fraction of the day.
|
||||
export function julianDay(d: Date): number {
|
||||
return d.getTime() / 86400000 + 2440587.5;
|
||||
}
|
||||
|
||||
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||
//
|
||||
// Returned together because they are computed from the same intermediates and
|
||||
// every caller needs both — splitting them into two exported functions would
|
||||
// double the work at each call for the sake of a tidier signature.
|
||||
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||
const jd = julianDay(date);
|
||||
const n = jd - 2451545.0; // days from J2000.0
|
||||
|
||||
// Mean longitude and mean anomaly of the Sun.
|
||||
const L = (280.460 + 0.9856474 * n) % 360;
|
||||
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||
|
||||
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||
|
||||
// Obliquity of the ecliptic, slowly decreasing.
|
||||
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||
|
||||
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||
|
||||
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||
// terminator about the prime meridian. Six months of a plausible-looking
|
||||
// wrong answer is exactly the kind of bug that survives review.
|
||||
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||
if (ra < 0) ra += 360;
|
||||
|
||||
// Greenwich mean sidereal time, in degrees.
|
||||
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||
|
||||
let gha = gmst - ra;
|
||||
gha = ((gha % 360) + 360) % 360;
|
||||
return { dec, gha };
|
||||
}
|
||||
|
||||
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||
// sits at `altitude` degrees above (or below) the horizon.
|
||||
//
|
||||
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||
// of the grey line as operators use the term.
|
||||
//
|
||||
// Returns null where no such latitude exists at that longitude — inside the
|
||||
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const h = altitude * DEG;
|
||||
|
||||
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||
//
|
||||
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||
// local in December came out as daylight because the branch chosen put the
|
||||
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||
// entirely plausible and is exactly wrong.
|
||||
//
|
||||
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||
// which is the physical requirement — the twilight curve is a small offset
|
||||
// from the terminator, never on the other side of the planet.
|
||||
const A = Math.sin(d);
|
||||
const B = Math.cos(d) * Math.cos(ha);
|
||||
const R = Math.hypot(A, B);
|
||||
if (R === 0) return null;
|
||||
const s = Math.sin(h) / R;
|
||||
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||
|
||||
// The classical terminator. Division by zero is deliberate and correct here:
|
||||
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||
// exactly what atan(±Infinity) = ±90° expresses.
|
||||
const t = Math.tan(d);
|
||||
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||
|
||||
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||
const fold = (x: number) => {
|
||||
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||
if (v > 90) v = 180 - v;
|
||||
if (v < -90) v = -180 - v;
|
||||
return v;
|
||||
};
|
||||
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||
}
|
||||
|
||||
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||
//
|
||||
// The ring runs along the terminator from west to east and closes along
|
||||
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||
// getting it wrong shades the lit half of the planet.
|
||||
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ring: [number, number][] = [];
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||
// ring stays closed rather than tearing open across the map.
|
||||
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||
}
|
||||
|
||||
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||
const pole = dec > 0 ? -90 : 90;
|
||||
ring.push([pole, 180]);
|
||||
ring.push([pole, -180]);
|
||||
return ring;
|
||||
}
|
||||
@@ -6,11 +6,52 @@
|
||||
// back to the DB copy and re-seed the cache.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||
// the active profile id). The localStorage cache, however, is one namespace for
|
||||
// the whole WebView, so without scoping it too a profile switch would keep
|
||||
// serving the previous profile's cached layout and the correct per-profile DB
|
||||
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||
// once the active profile is known and updated on every profile switch.
|
||||
let lsScope = '';
|
||||
|
||||
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||
// the grids read their state (at startup) and again whenever the active profile
|
||||
// changes so each profile keeps its own column layout / widths.
|
||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||
markReady();
|
||||
}
|
||||
|
||||
// The active profile is only known after an async call, but the grids mount and
|
||||
// read their state on the first paint. A grid that read before the scope was set
|
||||
// looked up the UNSCOPED cache key (always a miss) and then saved under the
|
||||
// scoped one — so its layout could never be restored, only rewritten. Grids now
|
||||
// wait for this instead of racing it.
|
||||
//
|
||||
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
|
||||
// still come up with whatever the unscoped cache holds rather than hang with no
|
||||
// columns restored.
|
||||
let markReady: () => void = () => {};
|
||||
const gridPrefsReady = new Promise<void>((resolve) => {
|
||||
markReady = resolve;
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
|
||||
export function whenGridPrefsReady(): Promise<void> {
|
||||
return gridPrefsReady;
|
||||
}
|
||||
|
||||
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||
function lsKey(key: string): string {
|
||||
return lsScope + key;
|
||||
}
|
||||
|
||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||
// to apply instantly, before the async DB round-trip).
|
||||
export function loadLocal(key: string): any[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const raw = localStorage.getItem(lsKey(key));
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
@@ -19,25 +60,70 @@ export function loadLocal(key: string): any[] | null {
|
||||
}
|
||||
|
||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
||||
try {
|
||||
const v = await GetUIPref(key);
|
||||
const parsed = v ? JSON.parse(v) : null;
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
//
|
||||
// GetUIPref returns an ERROR — not "" — while the settings store is still
|
||||
// coming up and not yet scoped to the active profile. Treating that as "no
|
||||
// preference" was the silent data-loss path: the grid rendered defaults and the
|
||||
// first column event then wrote those defaults over the good saved copy. So
|
||||
// retry for a few seconds instead, and only give up on a real absence.
|
||||
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const v = await GetUIPref(key);
|
||||
const parsed = v ? JSON.parse(v) : null;
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
// Not ready yet (or a parse failure on a corrupt value — one more read
|
||||
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
|
||||
// frame, so dragging one border used to fire dozens of settings writes; the
|
||||
// cache write stays synchronous so nothing is lost if the window closes.
|
||||
const pendingDB = new Map<string, string>();
|
||||
const dbTimers = new Map<string, number>();
|
||||
|
||||
export function saveState(key: string, state: any[]) {
|
||||
const json = JSON.stringify(state);
|
||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||
pendingDB.set(key, json);
|
||||
const prev = dbTimers.get(key);
|
||||
if (prev) clearTimeout(prev);
|
||||
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
|
||||
}
|
||||
|
||||
function flushOne(key: string) {
|
||||
const json = pendingDB.get(key);
|
||||
dbTimers.delete(key);
|
||||
if (json == null) return;
|
||||
pendingDB.delete(key);
|
||||
SetUIPref(key, json).catch(() => {
|
||||
// The store may not be scoped yet at startup. Keep the value and try once
|
||||
// more shortly — dropping it here is how a fresh profile ended up with no
|
||||
// portable copy at all.
|
||||
pendingDB.set(key, json);
|
||||
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
|
||||
});
|
||||
}
|
||||
|
||||
// flushGridPrefs writes any debounced state immediately. Call it when the app is
|
||||
// about to close so a resize made in the last moments still reaches the DB.
|
||||
export function flushGridPrefs() {
|
||||
for (const key of Array.from(pendingDB.keys())) {
|
||||
const t = dbTimers.get(key);
|
||||
if (t) clearTimeout(t);
|
||||
flushOne(key);
|
||||
}
|
||||
}
|
||||
|
||||
// seedLocal writes a value into the cache without touching the DB (used after
|
||||
// hydrating the cache from the DB on a fresh machine).
|
||||
export function seedLocal(key: string, state: any[]) {
|
||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
||||
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { themeQuartz } from 'ag-grid-community';
|
||||
|
||||
// Shared AG-Grid theme for every logbook table (Recent QSOs, Worked-before,
|
||||
// Cluster, Net, QSL Manager). Colours are wired to the app's semantic CSS
|
||||
// variables (see style.css) instead of hardcoded hex, so the grids follow the
|
||||
// active theme (light-warm / dark-warm / graphite / high-contrast) at runtime —
|
||||
// flipping <html data-theme> re-resolves these var() references with no
|
||||
// re-render. Keep this the single source of truth; per-grid tweaks (e.g. row
|
||||
// height) chain a `.withParams({...})` on top.
|
||||
export const hamlogGridTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: 'var(--card)',
|
||||
foregroundColor: 'var(--foreground)',
|
||||
headerBackgroundColor: 'var(--muted)',
|
||||
headerTextColor: 'var(--muted-foreground)',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: 'color-mix(in srgb, var(--muted) 40%, var(--card))',
|
||||
rowHoverColor: 'color-mix(in srgb, var(--accent) 55%, var(--card))',
|
||||
selectedRowBackgroundColor: 'var(--accent)',
|
||||
borderColor: 'var(--border)',
|
||||
rowBorder: { color: 'var(--border)', width: 1 },
|
||||
columnBorder: { color: 'var(--border)', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: 'var(--primary)',
|
||||
iconSize: 12,
|
||||
});
|
||||
+552
-84
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
||||
// Colouring for QSL / confirmation status letters.
|
||||
//
|
||||
// These columns are read by COLOUR before they are read as letters: an operator
|
||||
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||
// hundred times.
|
||||
//
|
||||
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||
// and a column of coloured boxes would shout louder than the callsign it
|
||||
// belongs to.
|
||||
//
|
||||
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||
// and the contrast stays right on the light ones.
|
||||
|
||||
// ADIF confirmation values, and what they mean to the operator:
|
||||
// Y confirmed / sent
|
||||
// N not sent, not received
|
||||
// R requested — queued, waiting on the other station or the service
|
||||
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||
// good news nor bad, and colouring it would put it in one camp or the
|
||||
// other.
|
||||
//
|
||||
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||
// rather than guessed at.
|
||||
export function qslStatusClass(value: unknown): string {
|
||||
switch (String(value ?? '').trim().toUpperCase()) {
|
||||
case 'Y':
|
||||
return 'text-success';
|
||||
case 'N':
|
||||
return 'text-destructive';
|
||||
case 'R':
|
||||
return 'text-info';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||
// status columns already used so the letters stay in a straight column.
|
||||
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||
const c = qslStatusClass(p?.value);
|
||||
return c ? 'font-mono ' + c : 'font-mono';
|
||||
}
|
||||
@@ -14,3 +14,25 @@ export function sMeterRST(s: number, overDb: number, mode?: string): string {
|
||||
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||
return `5${strength}`;
|
||||
}
|
||||
|
||||
// RST dropdown lists, shared by the entry form and the QSO editor so both offer
|
||||
// the same per-mode choices (Settings → Modes → RST report lists).
|
||||
export type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||
|
||||
// rstCategory maps an ADIF mode to its RST family (phone / cw / digital).
|
||||
export function rstCategory(mode: string): keyof RSTLists {
|
||||
const m = (mode || '').toUpperCase();
|
||||
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
|
||||
if (digital.includes(m)) return 'digital';
|
||||
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||
return 'phone';
|
||||
}
|
||||
|
||||
// rstOptions returns the valid report choices for a mode from the user's
|
||||
// editable lists, with a tiny fallback before they load.
|
||||
export function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||
const cat = rstCategory(mode);
|
||||
const l = lists[cat];
|
||||
if (l && l.length) return l;
|
||||
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// The "extra" spot markers — the ones ORTHOGONAL to the entity status.
|
||||
//
|
||||
// A spot's entity status (new DXCC / new band / new slot / worked) answers one
|
||||
// question. These answer others that can be true at the same time: the park is
|
||||
// new even if the entity is worked, the county is new even if the band is not.
|
||||
// Both the DX-cluster list and the band map show them, and they have to agree —
|
||||
// the same fact must not be green in one panel and violet in the next, so the
|
||||
// colours live HERE and nowhere else. This is also the table a per-marker colour
|
||||
// setting will drive, which is why it is a table rather than three constants.
|
||||
//
|
||||
// Colour choices:
|
||||
// POTA green — parks; the association is worth keeping
|
||||
// county violet — a chart hue, because every semantic token was already
|
||||
// spoken for: red, orange and yellow are the entity
|
||||
// statuses, blue is "callsign worked", green is POTA
|
||||
// worked blue — matches the WKD-CALL badge the cluster list has always used
|
||||
// prefix yellow — unchanged
|
||||
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call';
|
||||
|
||||
export type SpotMarker = {
|
||||
key: SpotMarkerKey;
|
||||
colour: string; // a CSS colour — a var() reference, so it follows the theme
|
||||
labelKey: string; // i18n key, short form (badge / legend)
|
||||
};
|
||||
|
||||
// Order is the display order, in both panels.
|
||||
export const SPOT_MARKERS: SpotMarker[] = [
|
||||
{ key: 'new_pota', colour: 'var(--success)', labelKey: 'clg2.newPota' },
|
||||
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||
];
|
||||
|
||||
export const markerColour = (key: SpotMarkerKey): string =>
|
||||
SPOT_MARKERS.find((m) => m.key === key)?.colour ?? 'var(--muted-foreground)';
|
||||
|
||||
// activeMarkers returns the markers set on a status entry, in display order.
|
||||
export function activeMarkers(e: Record<string, unknown> | undefined): SpotMarker[] {
|
||||
if (!e) return [];
|
||||
return SPOT_MARKERS.filter((m) => !!e[m.key]);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Sunrise / sunset for a station, in UTC.
|
||||
//
|
||||
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||
// never disagree, and there is a whole comment block over there about a sign
|
||||
// error that produced six months of plausible-looking wrong answers. One source
|
||||
// of astronomy, one place to be wrong.
|
||||
//
|
||||
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||
// time would raise the question "local to whom — me or the DX?".
|
||||
import { sunPosition } from './greyline';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||
// the horizon plus atmospheric refraction.
|
||||
const H0 = -0.833;
|
||||
|
||||
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const lat = latDeg * DEG;
|
||||
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||
}
|
||||
|
||||
export type SunTimes = {
|
||||
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||
set: string;
|
||||
polarDay: boolean; // Sun stays up all day
|
||||
polarNight: boolean; // Sun never comes up
|
||||
};
|
||||
|
||||
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||
// crosses the rise/set altitude.
|
||||
//
|
||||
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||
// special-casing for the polar cases and for days where the declination shifts
|
||||
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||
// result is accurate to well under a minute despite the coarse scan.
|
||||
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||
|
||||
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
const minute = 60000;
|
||||
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||
const anyUp = prev > H0;
|
||||
let anyDown = prev <= H0;
|
||||
let sawUp = anyUp;
|
||||
|
||||
for (let i = 1; i <= 1440; i++) {
|
||||
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||
if (alt > H0) sawUp = true; else anyDown = true;
|
||||
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
}
|
||||
prev = alt;
|
||||
}
|
||||
if (!out.rise && !out.set) {
|
||||
out.polarDay = sawUp && !anyDown;
|
||||
out.polarNight = !sawUp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// frac is where between two samples the altitude crosses H0.
|
||||
function frac(a: number, b: number): number {
|
||||
const d = b - a;
|
||||
return d === 0 ? 0 : (H0 - a) / d;
|
||||
}
|
||||
|
||||
function fmt(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Capitalisation helpers for the free-text QSO fields (Name / QTH / Comment /
|
||||
// Note).
|
||||
//
|
||||
// The same station reaches the log SHOUTED by QRZ, lower-cased by a hurried
|
||||
// operator, and in whatever case the other logger stored it in an imported
|
||||
// ADIF — so one callsign ends up as "JEAN", "jean" and "Jean" across a log,
|
||||
// and sorting or reading a QTH column becomes a mess.
|
||||
//
|
||||
// Both helpers are meant to run on BLUR, never per keystroke: rewriting the
|
||||
// value while the operator is still typing a word fights them mid-word (see
|
||||
// the controlled-input note in CLAUDE.md).
|
||||
|
||||
// titleCase upper-cases the first letter of every word and lower-cases the
|
||||
// rest. Separators are kept, so "SAINT-JULIEN" → "Saint-Julien" and
|
||||
// "o'brien" → "O'Brien".
|
||||
export function titleCase(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/(^|[\s\-'’/.])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
|
||||
}
|
||||
|
||||
// sentenceCase upper-cases the first letter and leaves everything after it as
|
||||
// typed. A comment routinely carries callsigns, modes and abbreviations
|
||||
// ("TNX QSO F5ABC, FT8 59") that lower-casing would quietly destroy — which is
|
||||
// why this is NOT titleCase.
|
||||
export function sentenceCase(s: string): string {
|
||||
return s.replace(/^(\s*)(\p{L})/u, (_m, sp: string, ch: string) => sp + ch.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||
import { writeUiPref } from './uiPref';
|
||||
import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||
// travels with the data/ folder like the language).
|
||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic'
|
||||
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
|
||||
|
||||
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
||||
// then darks, with high-contrast last — it is an accessibility choice, not a
|
||||
// taste one, and listing it among the moods buries it.
|
||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
'light-warm', 'light-cool', 'light-sage', 'light-nordic',
|
||||
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
|
||||
'high-contrast',
|
||||
];
|
||||
|
||||
export const LS_KEY = 'opslog.theme';
|
||||
// A fresh install starts DARK. A shack is usually a dim room and the screen is
|
||||
// looked at for hours; every other logger defaults the same way. Graphite
|
||||
// specifically, because that is what 'auto' already resolves to for a dark
|
||||
// system — so the two paths agree instead of landing on different darks.
|
||||
const DEFAULT: ThemeChoice = 'dark-graphite';
|
||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||
|
||||
function systemDark(): boolean {
|
||||
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
|
||||
}
|
||||
|
||||
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
|
||||
function resolve(choice: ThemeChoice): string {
|
||||
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
|
||||
return choice;
|
||||
}
|
||||
|
||||
function readStored(): ThemeChoice {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
|
||||
if (v && ALL.includes(v)) return v;
|
||||
} catch { /* private mode */ }
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
// Stamp the resolved theme onto <html data-theme>.
|
||||
export function applyThemeToDom(choice: ThemeChoice): void {
|
||||
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
|
||||
}
|
||||
|
||||
// Call before the first render (main.tsx) so there is no flash of the default
|
||||
// palette while React boots.
|
||||
export function initTheme(): void { applyThemeToDom(readStored()); }
|
||||
|
||||
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
|
||||
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
|
||||
export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||
// Set once the operator changes the theme by hand, so the self-heal below
|
||||
// never clobbers a fresh choice with a value it read a moment earlier.
|
||||
const userPicked = useRef(false);
|
||||
|
||||
const setTheme = useCallback((t: ThemeChoice) => {
|
||||
userPicked.current = true;
|
||||
setThemeState(t);
|
||||
applyThemeToDom(t);
|
||||
writeUiPref(LS_KEY, t);
|
||||
}, []);
|
||||
|
||||
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
|
||||
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
|
||||
// while the backend was still starting (settings store not wired yet → GetUIPref
|
||||
// returned "" with no error, so nothing was restored) — the "restart lands on
|
||||
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
|
||||
// backend is up and apply it, retrying briefly to ride out a slow startup.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let tries = 0;
|
||||
const load = () => {
|
||||
tries += 1;
|
||||
GetUIPref(LS_KEY).then((raw) => {
|
||||
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
|
||||
// empty/invalid one means the theme is genuinely unset → keep the default.
|
||||
// Either way we're done — do NOT retry (retrying only matters while the
|
||||
// backend is still starting, which now surfaces as a rejected promise).
|
||||
if (cancelled || userPicked.current) return;
|
||||
const v = raw as ThemeChoice;
|
||||
if (v && ALL.includes(v)) {
|
||||
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||
setThemeState(v);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Settings store not ready yet — a brief startup window before the backend
|
||||
// has opened the local DB and built the store (the frontend can query it
|
||||
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
|
||||
// lost to the light default after an update cleared localStorage.
|
||||
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
|
||||
});
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||
useEffect(() => {
|
||||
if (theme !== 'auto') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => applyThemeToDom('auto');
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [theme]);
|
||||
|
||||
// Keep the DOM attribute in sync with state.
|
||||
useEffect(() => { applyThemeToDom(theme); }, [theme]);
|
||||
|
||||
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||
}
|
||||
@@ -5,26 +5,46 @@
|
||||
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||
// identical after a copy.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||
// localStorage copy is just a fast, synchronous cache).
|
||||
const PORTABLE_KEYS = [
|
||||
'opslog.theme', // UI colour theme (light-warm / dark-warm / graphite / high-contrast / auto)
|
||||
'hamlog.qsoLimit', // QSO list page size
|
||||
'bandmap.side', // band map docked left / right
|
||||
'bandmap.show', // band map open / closed
|
||||
'opslog.autofocusWB', // auto-focus Worked-before
|
||||
'hamlog.filterPresets', // Filter Builder saved presets
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||
// Cluster filter selections — restored on reopen.
|
||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
// through this global path would fight that per-profile scoping.
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
@@ -50,5 +70,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||
export function writeUiPref(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
||||
SetUIPref(key, value).catch((e: any) => {
|
||||
// The cache still holds it, so the interface behaves — but the BACKEND
|
||||
// reads some of these keys too (digital-mode grouping decides how the
|
||||
// cluster judges a slot). A write that reaches localStorage and not the
|
||||
// database makes the two disagree, and used to do so in silence.
|
||||
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
+21
-3
@@ -4,6 +4,9 @@ import './style.css'
|
||||
import App from './App'
|
||||
import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||
import { reloadDateFormat } from './lib/dateFormat'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
@@ -13,11 +16,26 @@ const root = createRoot(container!)
|
||||
// app's synchronous reads see the values copied along with the data/ folder.
|
||||
// Render regardless of the outcome so a backend hiccup never blocks startup.
|
||||
syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
// The date format module read localStorage at import time, which is BEFORE
|
||||
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||
// copied data/ folder would show ISO until the next launch.
|
||||
reloadDateFormat()
|
||||
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||
// rejected promises. Installed before the first render so a fault during
|
||||
// startup is reported too.
|
||||
installGlobalErrorLogging()
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<App/>
|
||||
</I18nProvider>
|
||||
<ErrorBoundary>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
+926
-35
@@ -3,43 +3,900 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* ===== Warm taupe & burnt-orange palette =====
|
||||
A step darker than pure cream: the body is a soft taupe so white-ish
|
||||
cards lift off the surface, giving real depth without going dark.
|
||||
Borders are more defined; muted surfaces sit between bg and card so
|
||||
secondary chrome (toolbars, table headers) reads as quietly recessed.
|
||||
*/
|
||||
@theme {
|
||||
--color-background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||
--color-foreground: #1c1917; /* stone-900 */
|
||||
/* ============================================================================
|
||||
THEME SYSTEM
|
||||
----------------------------------------------------------------------------
|
||||
Every color is a semantic CSS variable declared per theme below. The
|
||||
`@theme inline` block points Tailwind's color utilities (bg-*, text-*,
|
||||
border-*, ring-*, …) directly at those variables, so switching the
|
||||
`data-theme` attribute on <html> reskins the whole UI at runtime with no
|
||||
rebuild. Four themes ship: two light-neutral surfaces and status families
|
||||
for OK / warning / caution / danger / info that adapt to each surface.
|
||||
|
||||
--color-card: #faf6ea; /* lifted off-white cream */
|
||||
--color-card-foreground: #1c1917;
|
||||
Semantic status vocabulary (use these, NOT raw palette colors):
|
||||
success — worked / confirmed / data / OK (was emerald)
|
||||
warning — new-band / attention (was amber)
|
||||
caution — new-mode / new-slot / lighter alert (was yellow)
|
||||
danger — new / split / negative (was rose)
|
||||
info — informational (was sky/blue)
|
||||
destructive — hard errors / live REC / delete (was red)
|
||||
Each family has: base (dots/bars/solid), -foreground (text on base),
|
||||
-muted (soft pill fill), -muted-foreground (text on pill), -border.
|
||||
========================================================================= */
|
||||
|
||||
--color-popover: #faf6ea;
|
||||
--color-popover-foreground: #1c1917;
|
||||
/* ---- Theme 1: Warm light (default) — warm taupe & burnt orange ---------- */
|
||||
:root,
|
||||
[data-theme="light-warm"] {
|
||||
--background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||
--foreground: #1c1917; /* stone-900 */
|
||||
--card: #faf6ea; /* lifted off-white cream */
|
||||
--card-foreground: #1c1917;
|
||||
--popover: #faf6ea;
|
||||
--popover-foreground: #1c1917;
|
||||
--primary: #b8410c; /* burnt orange, slightly deeper */
|
||||
--primary-foreground: #fff7ed;
|
||||
--secondary: #ddd2b8;
|
||||
--secondary-foreground: #1c1917;
|
||||
--muted: #ddd2b8; /* toolbars / table headers */
|
||||
--muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||
--accent: #f8d6a9; /* warm amber-cream */
|
||||
--accent-foreground: #7c2d12; /* orange-900 */
|
||||
--destructive: #a51c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #c8b994; /* deeper warm beige border */
|
||||
--input: #c8b994;
|
||||
--ring: #d97706; /* amber-600 focus ring */
|
||||
|
||||
--color-primary: #b8410c; /* burnt orange, slightly deeper */
|
||||
--color-primary-foreground: #fff7ed;
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--color-secondary: #ddd2b8; /* a touch under the bg */
|
||||
--color-secondary-foreground: #1c1917;
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--color-muted: #ddd2b8; /* toolbars / table headers */
|
||||
--color-muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--color-accent: #f8d6a9; /* warm amber-cream */
|
||||
--color-accent-foreground: #7c2d12; /* orange-900 */
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--color-destructive: #a51c1c;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
--color-border: #c8b994; /* deeper warm beige border */
|
||||
--color-input: #c8b994;
|
||||
--color-ring: #d97706; /* amber-600 focus ring */
|
||||
/* Band/mode matrix (BandSlotGrid) — original emerald/indigo/stone ramp. */
|
||||
--mx-call-conf: #15803d; /* call confirmed */
|
||||
--mx-call-work: #6ee7b7; /* call worked */
|
||||
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
||||
--mx-dx-work: #a5b4fc; /* entity worked */
|
||||
--mx-none: #e7e5e4; /* never worked */
|
||||
|
||||
--color-ok: #15803d;
|
||||
--color-warn: #b45309;
|
||||
--scrollbar-thumb: #b8a880;
|
||||
--scrollbar-thumb-hover: #968455;
|
||||
--card-shadow: 0 1px 2px rgba(28, 25, 23, 0.05), 0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
|
||||
[data-theme="light-cool"] {
|
||||
--background: #f4f6f8; /* cool blue-white */
|
||||
--foreground: #0f172a; /* slate-900 */
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f172a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0f172a;
|
||||
--primary: #2563eb; /* blue-600 */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #e2e8f0; /* slate-200 */
|
||||
--secondary-foreground: #0f172a;
|
||||
--muted: #e6ebf1; /* toolbars / table headers */
|
||||
--muted-foreground: #475569; /* slate-600 — readable on muted */
|
||||
--accent: #dbeafe; /* blue-100 */
|
||||
--accent-foreground: #1e3a8a; /* blue-900 */
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #d3dae2; /* cool slate border */
|
||||
--input: #d3dae2;
|
||||
--ring: #3b82f6; /* blue-500 focus ring */
|
||||
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #6ee7b7;
|
||||
--mx-dx-conf: #3730a3;
|
||||
--mx-dx-work: #a5b4fc;
|
||||
--mx-none: #e2e8f0;
|
||||
|
||||
--scrollbar-thumb: #b6c2d1;
|
||||
--scrollbar-thumb-hover: #8fa1b5;
|
||||
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
|
||||
[data-theme="light-sage"] {
|
||||
--background: #eef1ec; /* pale sage */
|
||||
--foreground: #1a2420; /* deep pine-ink */
|
||||
--card: #f8faf6; /* lifted off-white with a green cast */
|
||||
--card-foreground: #1a2420;
|
||||
--popover: #f8faf6;
|
||||
--popover-foreground: #1a2420;
|
||||
--primary: #2f855a; /* deep sage green */
|
||||
--primary-foreground: #f0fdf4;
|
||||
--secondary: #dde5db;
|
||||
--secondary-foreground: #1a2420;
|
||||
--muted: #dde5db; /* toolbars / table headers */
|
||||
--muted-foreground: #445048; /* muted pine — readable on muted */
|
||||
--accent: #d7e8d5; /* soft green */
|
||||
--accent-foreground: #1e4634; /* forest-900 */
|
||||
--destructive: #b91c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #cdd8ce; /* soft sage border */
|
||||
--input: #cdd8ce;
|
||||
--ring: #38a169; /* green focus ring */
|
||||
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #6ee7b7;
|
||||
--mx-dx-conf: #3730a3;
|
||||
--mx-dx-work: #a5b4fc;
|
||||
--mx-none: #dde5db;
|
||||
|
||||
--scrollbar-thumb: #b3c2b3;
|
||||
--scrollbar-thumb-hover: #91a591;
|
||||
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
|
||||
[data-theme="dim-slate"] {
|
||||
--background: #343b47; /* medium slate — light text, not deep dark */
|
||||
--foreground: #eceef2;
|
||||
--card: #3d4552; /* lifted panel */
|
||||
--card-foreground: #eceef2;
|
||||
--popover: #3d4552;
|
||||
--popover-foreground: #eceef2;
|
||||
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
|
||||
--primary-foreground: #241206;
|
||||
--secondary: #454e5c;
|
||||
--secondary-foreground: #eceef2;
|
||||
--muted: #414a58; /* toolbars / table headers */
|
||||
--muted-foreground: #b4bcc9; /* readable on muted */
|
||||
--accent: #495468; /* hover / selection tint */
|
||||
--accent-foreground: #dce3ef;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #1a0808;
|
||||
--destructive-muted: #3f1f1f;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #4e5867;
|
||||
--input: #4e5867;
|
||||
--ring: #fdba74; /* orange-300 focus ring */
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #06231a;
|
||||
--success-muted: #1c3d34;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #2a5f4c;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #241a06;
|
||||
--warning-muted: #41371a;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #5e4d22;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #242006;
|
||||
--caution-muted: #403b1a;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #5b5222;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #260a11;
|
||||
--danger-muted: #421f28;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #63303c;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #061f2b;
|
||||
--info-muted: #16384a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #1d5670;
|
||||
|
||||
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #4a5262;
|
||||
|
||||
--scrollbar-thumb: #565f70;
|
||||
--scrollbar-thumb-hover: #6b7588;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||
[data-theme="dark-warm"] {
|
||||
--background: #221d18;
|
||||
--foreground: #ede6d8;
|
||||
--card: #2e2820;
|
||||
--card-foreground: #ede6d8;
|
||||
--popover: #2e2820;
|
||||
--popover-foreground: #ede6d8;
|
||||
--primary: #e07a2e; /* brighter burnt orange for dark */
|
||||
--primary-foreground: #1a1512;
|
||||
--secondary: #3a3229;
|
||||
--secondary-foreground: #ede6d8;
|
||||
--muted: #352e26;
|
||||
--muted-foreground: #bcae9a;
|
||||
--accent: #4a3a28;
|
||||
--accent-foreground: #f8d6a9;
|
||||
--destructive: #e5484d;
|
||||
--destructive-foreground: #1a1512;
|
||||
--destructive-muted: #3a1414;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #473e33;
|
||||
--input: #473e33;
|
||||
--ring: #e8853a;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #0e2a20;
|
||||
--success-muted: #123024;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #1e5540;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #2a1f08;
|
||||
--warning-muted: #3a2e10;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #5c4a18;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #2a2607;
|
||||
--caution-muted: #38330f;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #574f18;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #2a0d13;
|
||||
--danger-muted: #3a1720;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #5c2530;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #08222e;
|
||||
--info-muted: #0c2a3a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #164a5c;
|
||||
|
||||
/* Matrix: bright confirmed, medium-solid worked (clearly visible on dark),
|
||||
warm-gray not-worked lifted off the card. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #453d33;
|
||||
|
||||
--scrollbar-thumb: #5c5044;
|
||||
--scrollbar-thumb-hover: #7a6a58;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 240, 220, 0.04);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 3: Graphite dark — cool slate, technical look --------------- */
|
||||
[data-theme="dark-graphite"] {
|
||||
--background: #16181d;
|
||||
--foreground: #e6e8ec;
|
||||
--card: #1f232b;
|
||||
--card-foreground: #e6e8ec;
|
||||
--popover: #1f232b;
|
||||
--popover-foreground: #e6e8ec;
|
||||
--primary: #f97316; /* orange-500 pops on slate */
|
||||
--primary-foreground: #0a0c10;
|
||||
--secondary: #272c35;
|
||||
--secondary-foreground: #e6e8ec;
|
||||
--muted: #232830;
|
||||
--muted-foreground: #9aa2b1;
|
||||
--accent: #2a3240;
|
||||
--accent-foreground: #cbd5e5;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #0a0c10;
|
||||
--destructive-muted: #331414;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #2e343f;
|
||||
--input: #2e343f;
|
||||
--ring: #fb8c3c;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #06231a;
|
||||
--success-muted: #0f2e26;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #17513f;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #241a06;
|
||||
--warning-muted: #33290f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3f16;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #242006;
|
||||
--caution-muted: #322e0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4c4416;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #260a11;
|
||||
--danger-muted: #34141d;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #532430;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #061f2b;
|
||||
--info-muted: #0b2938;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #124659;
|
||||
|
||||
/* Matrix: bright confirmed, medium-solid worked, cool-gray not-worked. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #333a45;
|
||||
|
||||
--scrollbar-thumb: #3a4150;
|
||||
--scrollbar-thumb-hover: #4e5768;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(230, 232, 236, 0.04);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 4: High contrast — pure black / white, max legibility ------- */
|
||||
[data-theme="high-contrast"] {
|
||||
--background: #000000;
|
||||
--foreground: #ffffff;
|
||||
--card: #0d0d0d;
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #0d0d0d;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ff7a1a;
|
||||
--primary-foreground: #000000;
|
||||
--secondary: #1a1a1a;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #141414;
|
||||
--muted-foreground: #d4d4d4;
|
||||
--accent: #262626;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff4d4d;
|
||||
--destructive-foreground: #000000;
|
||||
--destructive-muted: #2a0808;
|
||||
--destructive-muted-foreground: #ff9999;
|
||||
--border: #3f3f3f;
|
||||
--input: #3f3f3f;
|
||||
--ring: #ffab5e;
|
||||
|
||||
--success: #22e37a;
|
||||
--success-foreground: #002313;
|
||||
--success-muted: #06231a;
|
||||
--success-muted-foreground: #6affb0;
|
||||
--success-border: #0f6b4a;
|
||||
|
||||
--warning: #ffcc33;
|
||||
--warning-foreground: #201900;
|
||||
--warning-muted: #2a2205;
|
||||
--warning-muted-foreground: #ffe08a;
|
||||
--warning-border: #6b5510;
|
||||
|
||||
--caution: #ffe14d;
|
||||
--caution-foreground: #201d00;
|
||||
--caution-muted: #2a2705;
|
||||
--caution-muted-foreground: #fff08a;
|
||||
--caution-border: #6b6210;
|
||||
|
||||
--danger: #ff6b7d;
|
||||
--danger-foreground: #23000a;
|
||||
--danger-muted: #2a0a12;
|
||||
--danger-muted-foreground: #ffb3bd;
|
||||
--danger-border: #6b1f2c;
|
||||
|
||||
--info: #4dc4ff;
|
||||
--info-foreground: #001a26;
|
||||
--info-muted: #052330;
|
||||
--info-muted-foreground: #a3e0ff;
|
||||
--info-border: #0f5a6b;
|
||||
|
||||
/* Matrix: max-contrast ramp on pure black. */
|
||||
--mx-call-conf: #2ee87f;
|
||||
--mx-call-work: #17a85a;
|
||||
--mx-dx-conf: #7d97ff;
|
||||
--mx-dx-work: #3646b0;
|
||||
--mx-none: #3a3a3a;
|
||||
|
||||
--scrollbar-thumb: #4a4a4a;
|
||||
--scrollbar-thumb-hover: #666666;
|
||||
--card-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 5: Indigo — deep blue-violet, electric accent ----------------
|
||||
The first seven themes are all neutral (beige, slate, graphite, black) with an
|
||||
orange accent, so "another dark theme" only ever meant another grey. These
|
||||
four carry a real hue in the SURFACES, not just in the accent, and each takes
|
||||
a different primary — violet, cyan, pink, indigo — so they are told apart at a
|
||||
glance in the picker.
|
||||
|
||||
Every one keeps the same contract: the semantic colours (success / warning /
|
||||
caution / danger / info) must stay recognisable as themselves. A logger is
|
||||
read for hours and "red = a problem" cannot become a decorative choice, so the
|
||||
hue budget is spent on the surfaces and the primary, never on the meanings. */
|
||||
[data-theme="dark-indigo"] {
|
||||
--background: #0e0f1f;
|
||||
--foreground: #e4e5f2;
|
||||
--card: #181a30;
|
||||
--card-foreground: #e4e5f2;
|
||||
--popover: #181a30;
|
||||
--popover-foreground: #e4e5f2;
|
||||
--primary: #7c6cff; /* electric violet */
|
||||
--primary-foreground: #0b0a1a;
|
||||
--secondary: #222542;
|
||||
--secondary-foreground: #e4e5f2;
|
||||
--muted: #1e2038;
|
||||
--muted-foreground: #9d9fbe;
|
||||
--accent: #2a2d51;
|
||||
--accent-foreground: #c7c9ee;
|
||||
--destructive: #f2555a;
|
||||
--destructive-foreground: #12030a;
|
||||
--destructive-muted: #34131c;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #2a2d4c;
|
||||
--input: #2a2d4c;
|
||||
--ring: #9b8dff;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #052018;
|
||||
--success-muted: #0e2f27;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #165241;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #221803;
|
||||
--warning-muted: #33280f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3e15;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #221e04;
|
||||
--caution-muted: #322d0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4b4315;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #250912;
|
||||
--danger-muted: #37151f;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #562533;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #051d29;
|
||||
--info-muted: #0b2a3a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #12475c;
|
||||
|
||||
/* The entity ramp moves to CYAN here: violet is the primary, and a violet
|
||||
"entity confirmed" cell would read as a button. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2c7a52;
|
||||
--mx-dx-conf: #22d3ee;
|
||||
--mx-dx-work: #1b6b7c;
|
||||
--mx-none: #2c2f4d;
|
||||
|
||||
--scrollbar-thumb: #383c63;
|
||||
--scrollbar-thumb-hover: #4c5182;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(228, 229, 242, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 6: Ocean — deep teal, cyan accent --------------------------- */
|
||||
[data-theme="dark-teal"] {
|
||||
--background: #061c21;
|
||||
--foreground: #dcecee;
|
||||
--card: #0d2c33;
|
||||
--card-foreground: #dcecee;
|
||||
--popover: #0d2c33;
|
||||
--popover-foreground: #dcecee;
|
||||
--primary: #22d3ee; /* cyan */
|
||||
--primary-foreground: #041d24;
|
||||
--secondary: #143b44;
|
||||
--secondary-foreground: #dcecee;
|
||||
--muted: #10333b;
|
||||
--muted-foreground: #92b4ba;
|
||||
--accent: #15424c;
|
||||
--accent-foreground: #a5d8e0;
|
||||
--destructive: #f2555a;
|
||||
--destructive-foreground: #12030a;
|
||||
--destructive-muted: #35161a;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #1a4652;
|
||||
--input: #1a4652;
|
||||
--ring: #5fe3f5;
|
||||
|
||||
--success: #4ade80;
|
||||
--success-foreground: #04220f;
|
||||
--success-muted: #0e3020;
|
||||
--success-muted-foreground: #86efac;
|
||||
--success-border: #17593a;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #221803;
|
||||
--warning-muted: #33280f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3e15;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #221e04;
|
||||
--caution-muted: #312d0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4a4315;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #250912;
|
||||
--danger-muted: #38161f;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #572634;
|
||||
|
||||
/* Info moves to a BLUE: a cyan "info" would be the primary colour again. */
|
||||
--info: #7aa2f7;
|
||||
--info-foreground: #04122e;
|
||||
--info-muted: #16233f;
|
||||
--info-muted-foreground: #a9c2fb;
|
||||
--info-border: #2b3d66;
|
||||
|
||||
--mx-call-conf: #4ade80;
|
||||
--mx-call-work: #2b7a4d;
|
||||
--mx-dx-conf: #a78bfa;
|
||||
--mx-dx-work: #5b4a9c;
|
||||
--mx-none: #1c4550;
|
||||
|
||||
--scrollbar-thumb: #235a68;
|
||||
--scrollbar-thumb-hover: #31788a;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(220, 236, 238, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 7: Plum — aubergine, magenta accent ------------------------- */
|
||||
[data-theme="dark-plum"] {
|
||||
--background: #180f1e;
|
||||
--foreground: #eee0f0;
|
||||
--card: #251830;
|
||||
--card-foreground: #eee0f0;
|
||||
--popover: #251830;
|
||||
--popover-foreground: #eee0f0;
|
||||
--primary: #f472b6; /* magenta-pink */
|
||||
--primary-foreground: #250518;
|
||||
--secondary: #33203f;
|
||||
--secondary-foreground: #eee0f0;
|
||||
--muted: #2b1b36;
|
||||
--muted-foreground: #b39dbd;
|
||||
--accent: #3b2648;
|
||||
--accent-foreground: #dcc2e6;
|
||||
--destructive: #f2555a;
|
||||
--destructive-foreground: #12030a;
|
||||
--destructive-muted: #3a1620;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #3a2547;
|
||||
--input: #3a2547;
|
||||
--ring: #f9a8d4;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #052018;
|
||||
--success-muted: #10302a;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #1a5745;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #221803;
|
||||
--warning-muted: #352a12;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #524019;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #221e04;
|
||||
--caution-muted: #342e12;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4e4519;
|
||||
|
||||
/* Danger goes RED here, not rose: a rose "danger" beside a pink primary is
|
||||
indistinguishable, and this is the one colour that must never be missed. */
|
||||
--danger: #f43f3f;
|
||||
--danger-foreground: #2a0606;
|
||||
--danger-muted: #3d1418;
|
||||
--danger-muted-foreground: #fca5a5;
|
||||
--danger-border: #5c2128;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #051d29;
|
||||
--info-muted: #0f2b3a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #16485d;
|
||||
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #60a5fa;
|
||||
--mx-dx-work: #35538c;
|
||||
--mx-none: #3a2647;
|
||||
|
||||
--scrollbar-thumb: #4a3159;
|
||||
--scrollbar-thumb-hover: #644176;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(238, 224, 240, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 8: Nordic light — crisp cool white, indigo accent ----------- */
|
||||
[data-theme="light-nordic"] {
|
||||
--background: #eef1f7;
|
||||
--foreground: #1b2130;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1b2130;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1b2130;
|
||||
--primary: #4f46e5; /* indigo */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #dfe4ee;
|
||||
--secondary-foreground: #1b2130;
|
||||
--muted: #e2e7f0;
|
||||
--muted-foreground: #4a5468;
|
||||
--accent: #dcdcfb;
|
||||
--accent-foreground: #312a91;
|
||||
--destructive: #b91c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #c9d1e0;
|
||||
--input: #c9d1e0;
|
||||
--ring: #6366f1;
|
||||
|
||||
--success: #15803d;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #14532d;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #b45309;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #a16207;
|
||||
--caution-foreground: #ffffff;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #dc2626;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #fee2e2;
|
||||
--danger-muted-foreground: #991b1b;
|
||||
--danger-border: #fca5a5;
|
||||
|
||||
--info: #0369a1;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Entity ramp shifts to TEAL: indigo is the primary here. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #86efac;
|
||||
--mx-dx-conf: #0f766e;
|
||||
--mx-dx-work: #99f6e4;
|
||||
--mx-none: #dde3ec;
|
||||
|
||||
--scrollbar-thumb: #b6c0d2;
|
||||
--scrollbar-thumb-hover: #93a0b8;
|
||||
--card-shadow: 0 1px 2px rgba(27, 33, 48, 0.06), 0 0 0 1px rgba(27, 33, 48, 0.03);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||
automatic inversion.
|
||||
|
||||
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||
shows.
|
||||
|
||||
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||
recedes toward the surface instead of glowing. */
|
||||
:root,
|
||||
[data-theme="light-warm"],
|
||||
[data-theme="light-cool"],
|
||||
[data-theme="light-sage"],
|
||||
[data-theme="light-nordic"] {
|
||||
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||
--chart-2: #1baf7a; /* aqua */
|
||||
--chart-3: #eda100; /* yellow */
|
||||
--chart-4: #008300; /* green */
|
||||
--chart-5: #4a3aa7; /* violet */
|
||||
--chart-6: #e34948; /* red */
|
||||
--chart-7: #e87ba4; /* magenta */
|
||||
--chart-8: #eb6834; /* orange */
|
||||
|
||||
--chart-seq-1: #cde2fb;
|
||||
--chart-seq-2: #9ec5f4;
|
||||
--chart-seq-3: #6da7ec;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #256abf;
|
||||
--chart-seq-6: #104281;
|
||||
}
|
||||
|
||||
[data-theme="dim-slate"],
|
||||
[data-theme="dark-warm"],
|
||||
[data-theme="dark-graphite"],
|
||||
[data-theme="high-contrast"],
|
||||
[data-theme="dark-indigo"],
|
||||
[data-theme="dark-teal"],
|
||||
[data-theme="dark-plum"] {
|
||||
--chart-1: #3987e5;
|
||||
--chart-2: #199e70;
|
||||
--chart-3: #c98500;
|
||||
--chart-4: #008300;
|
||||
--chart-5: #9085e9;
|
||||
--chart-6: #e66767;
|
||||
--chart-7: #d55181;
|
||||
--chart-8: #d95926;
|
||||
|
||||
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||
--chart-seq-1: #0d366b;
|
||||
--chart-seq-2: #184f95;
|
||||
--chart-seq-3: #256abf;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #6da7ec;
|
||||
--chart-seq-6: #9ec5f4;
|
||||
}
|
||||
|
||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||
the generated utilities reference var(--…) directly, so overriding a var in
|
||||
a [data-theme] block re-skins every utility at runtime. */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive-muted: var(--destructive-muted);
|
||||
--color-destructive-muted-foreground: var(--destructive-muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-success-muted: var(--success-muted);
|
||||
--color-success-muted-foreground: var(--success-muted-foreground);
|
||||
--color-success-border: var(--success-border);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-warning-muted: var(--warning-muted);
|
||||
--color-warning-muted-foreground: var(--warning-muted-foreground);
|
||||
--color-warning-border: var(--warning-border);
|
||||
|
||||
--color-caution: var(--caution);
|
||||
--color-caution-foreground: var(--caution-foreground);
|
||||
--color-caution-muted: var(--caution-muted);
|
||||
--color-caution-muted-foreground: var(--caution-muted-foreground);
|
||||
--color-caution-border: var(--caution-border);
|
||||
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-foreground: var(--danger-foreground);
|
||||
--color-danger-muted: var(--danger-muted);
|
||||
--color-danger-muted-foreground: var(--danger-muted-foreground);
|
||||
--color-danger-border: var(--danger-border);
|
||||
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
--color-info-muted: var(--info-muted);
|
||||
--color-info-muted-foreground: var(--info-muted-foreground);
|
||||
--color-info-border: var(--info-border);
|
||||
|
||||
--color-mx-call-conf: var(--mx-call-conf);
|
||||
--color-mx-call-work: var(--mx-call-work);
|
||||
--color-mx-dx-conf: var(--mx-dx-conf);
|
||||
--color-mx-dx-work: var(--mx-dx-work);
|
||||
--color-mx-none: var(--mx-none);
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
@@ -65,18 +922,52 @@
|
||||
}
|
||||
|
||||
/* Subtle elevation on every Card-styled surface so cards visibly sit on
|
||||
top of the taupe background — paper-on-paper feel. */
|
||||
top of the background — paper-on-paper feel, tuned per theme. */
|
||||
.bg-card {
|
||||
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05),
|
||||
0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* Warm scrollbar tuned to the deeper bg */
|
||||
/* Scrollbar tuned to each theme's surface */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #b8a880;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--color-background);
|
||||
border: 2px solid var(--background);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||
|
||||
/* ── Frameless window: drag region ──────────────────────────────────────────
|
||||
The OS title bar is gone (main.go, Frameless), so the app header IS the title
|
||||
bar and must be draggable. Everything interactive inside it opts OUT: a drag
|
||||
region swallows clicks, and without this the menus and toolbar buttons would
|
||||
move the window instead of doing their job. Opting out by ROLE rather than
|
||||
listing each child keeps a future button working without anyone remembering
|
||||
this rule. */
|
||||
.app-dragregion {
|
||||
--wails-draggable: drag;
|
||||
}
|
||||
.app-dragregion button,
|
||||
.app-dragregion a,
|
||||
.app-dragregion input,
|
||||
.app-dragregion select,
|
||||
.app-dragregion textarea,
|
||||
.app-dragregion nav,
|
||||
.app-dragregion [role='button'],
|
||||
.app-dragregion [role='menu'],
|
||||
.app-dragregion [data-no-drag] {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
|
||||
disappears on the dark themes. Invert it there — the control itself is worth
|
||||
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||
help. */
|
||||
Every temporal input carries the same glyph, so the rule names them all:
|
||||
datetime-local was left out at first and its icon stayed invisible. */
|
||||
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'],
|
||||
[data-theme='dark-indigo'], [data-theme='dark-teal'], [data-theme='dark-plum'])
|
||||
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(1.8);
|
||||
opacity: 0.9;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #968455; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.16.5';
|
||||
export const APP_VERSION = '0.24.2';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+421
-11
@@ -5,20 +5,34 @@ import {qso} from '../models';
|
||||
import {main} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {acom} from '../models';
|
||||
import {catemu} from '../models';
|
||||
import {antgenius} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {spe} from '../models';
|
||||
import {solar} from '../models';
|
||||
import {tunergenius} from '../models';
|
||||
import {webpub} from '../models';
|
||||
import {winkeyer} from '../models';
|
||||
import {alerts} from '../models';
|
||||
import {audio} from '../models';
|
||||
import {contest} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {udp} from '../models';
|
||||
import {lotwusers} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
import {scp} from '../models';
|
||||
|
||||
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function ADIFExtraFields():Promise<Array<string>>;
|
||||
|
||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||
|
||||
@@ -28,15 +42,41 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||
|
||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||
|
||||
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
|
||||
|
||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AudioMonitorActive():Promise<boolean>;
|
||||
|
||||
export function AudioStartMonitor():Promise<void>;
|
||||
|
||||
export function AudioStartTX():Promise<void>;
|
||||
|
||||
export function AudioStopMonitor():Promise<void>;
|
||||
|
||||
export function AudioStopTX():Promise<void>;
|
||||
|
||||
export function AudioTXActive():Promise<boolean>;
|
||||
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardFields():Promise<Array<string>>;
|
||||
|
||||
@@ -44,14 +84,24 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||
|
||||
export function AwardsFolder():Promise<string>;
|
||||
|
||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||
|
||||
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function BrowseExecutable():Promise<string>;
|
||||
|
||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CIVTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function CancelConfirmations():Promise<void>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
@@ -120,19 +170,37 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||
|
||||
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
|
||||
export function DownloadScp():Promise<number>;
|
||||
|
||||
export function DownloadULSCounties():Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
|
||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||
|
||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportAward(arg1:string):Promise<string>;
|
||||
|
||||
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
@@ -156,8 +224,14 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||
@@ -166,12 +240,16 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetANFT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetActiveSlice(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||
@@ -184,10 +262,24 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetDAX(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetLMSANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetLMSNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||
@@ -200,6 +292,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNRF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNRFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetPower(arg1:number):Promise<void>;
|
||||
@@ -208,14 +304,30 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetRIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetRNN(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||
@@ -224,12 +336,32 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetWNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexStopCW():Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetACOMStatus():Promise<acom.Status>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
|
||||
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||
|
||||
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||
|
||||
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||
|
||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||
|
||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||
@@ -238,7 +370,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||
|
||||
export function GetAward(arg1:string):Promise<award.Result>;
|
||||
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||
|
||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
@@ -248,6 +380,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
||||
|
||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||
|
||||
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||
|
||||
export function GetAwards():Promise<Array<award.Result>>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
@@ -258,14 +392,22 @@ export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetCWDecoderPitch():Promise<number>;
|
||||
|
||||
export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||
@@ -286,74 +428,142 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||
|
||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
|
||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||
|
||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||
|
||||
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||
|
||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||
|
||||
export function GetOperators():Promise<Array<string>>;
|
||||
|
||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||
|
||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||
|
||||
export function GetPOTAToken():Promise<string>;
|
||||
|
||||
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetQSORate():Promise<main.QSORate>;
|
||||
|
||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||
|
||||
export function GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSelfSpotSettings():Promise<main.SelfSpotSettings>;
|
||||
|
||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
|
||||
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||
|
||||
export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetTrackedAwards():Promise<Array<string>>;
|
||||
|
||||
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
|
||||
|
||||
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
|
||||
|
||||
export function GetUIPref(arg1:string):Promise<string>;
|
||||
|
||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||
|
||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||
|
||||
export function GetWebPublishConfig():Promise<webpub.Config>;
|
||||
|
||||
export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
||||
|
||||
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function GetWorkedCallVariants():Promise<boolean>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IcomRefresh():Promise<void>;
|
||||
|
||||
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
||||
|
||||
export function IcomSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function IcomSetAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function IcomSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetAPF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetAntenna(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAntiVOX(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetBreakIn(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetComp(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetCompLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetFilter(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetManualNotch(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetMicGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetMonLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetMonitor(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||
@@ -362,28 +572,60 @@ export function IcomSetNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetNotchPos(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPBTInner(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPBTOuter(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPTT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetPreamp(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFPower(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRIT(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScope(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScopeEdges(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetSquelch(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetVOXGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetXITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomStopCW():Promise<void>;
|
||||
|
||||
export function IcomTune():Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||
|
||||
export function KenwoodSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function KenwoodStopCW():Promise<void>;
|
||||
|
||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||
|
||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||
@@ -402,6 +644,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
@@ -416,10 +660,22 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LiveLastQSOAgeSec():Promise<number>;
|
||||
|
||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorReadElements():Promise<Array<number>>;
|
||||
|
||||
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||
@@ -458,6 +714,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function OpenADIFFile():Promise<string>;
|
||||
|
||||
export function OpenAwardsFolder():Promise<void>;
|
||||
|
||||
export function OpenDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
@@ -466,6 +724,14 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
||||
|
||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function PTTHotkeyDown():Promise<void>;
|
||||
|
||||
export function PTTHotkeyUp():Promise<void>;
|
||||
|
||||
export function PickADIFMonitorFile():Promise<string>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
@@ -476,6 +742,8 @@ export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||
|
||||
export function PublishLogNow():Promise<string>;
|
||||
|
||||
export function QSLDefaultTemplateID():Promise<number>;
|
||||
|
||||
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
||||
@@ -514,18 +782,38 @@ export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioManualReady():Promise<boolean>;
|
||||
|
||||
export function QSOAudioManualStart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioPlayOnAir():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioResume():Promise<boolean>;
|
||||
|
||||
export function QSOAudioStop():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
|
||||
export function RefreshYaesuPanel():Promise<void>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameLogbook(arg1:string):Promise<void>;
|
||||
|
||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
@@ -538,8 +826,14 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
export function RestartApp():Promise<void>;
|
||||
|
||||
export function RestartQSORecorder():Promise<void>;
|
||||
|
||||
export function RetryOfflineSync():Promise<number>;
|
||||
|
||||
export function RevealDataFolder():Promise<void>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
@@ -548,10 +842,22 @@ export function RotatorStop():Promise<void>;
|
||||
|
||||
export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SMTPConfigured():Promise<boolean>;
|
||||
|
||||
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SPESetPowerLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||
|
||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||
|
||||
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
|
||||
|
||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||
@@ -576,6 +882,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
||||
|
||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
@@ -594,16 +902,30 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||
|
||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
|
||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveTrackedAwards(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function SaveWebPublishConfig(arg1:webpub.Config):Promise<void>;
|
||||
|
||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||
|
||||
export function ScpLookup(arg1:string):Promise<scp.Result>;
|
||||
|
||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||
@@ -614,42 +936,102 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
|
||||
|
||||
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SendLogToDeveloper():Promise<void>;
|
||||
|
||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||
|
||||
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||
|
||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuBand(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuBreakIn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuMicGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuModeRaw(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuNarrow(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuPower(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuPreamp(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuSplitOffset(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuSquelch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TailLogFile(arg1:number):Promise<string>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
export function TestEQSLUpload():Promise<string>;
|
||||
@@ -668,10 +1050,28 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||
|
||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function TestWebPublishFTP(arg1:webpub.Config):Promise<string>;
|
||||
|
||||
export function TuneYaesuATU():Promise<void>;
|
||||
|
||||
export function TunerGeniusActivate(arg1:number):Promise<void>;
|
||||
|
||||
export function TunerGeniusAutotune():Promise<void>;
|
||||
|
||||
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
|
||||
|
||||
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function UILog(arg1:string):Promise<void>;
|
||||
|
||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||
|
||||
export function UltrabeamRetract():Promise<void>;
|
||||
|
||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||
@@ -690,6 +1090,8 @@ export function UploadCallsign(arg1:string):Promise<string>;
|
||||
|
||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||
|
||||
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
|
||||
|
||||
export function WinkeyerBackspace():Promise<void>;
|
||||
|
||||
export function WinkeyerConnect():Promise<void>;
|
||||
@@ -702,4 +1104,12 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function WinkeyerStop():Promise<void>;
|
||||
|
||||
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function YaesuStopCW():Promise<void>;
|
||||
|
||||
export function YaesuZeroIn():Promise<void>;
|
||||
|
||||
+826
-22
File diff suppressed because it is too large
Load Diff
+1660
-13
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ require (
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jlaffaye/ftp v0.2.2
|
||||
github.com/moutend/go-wca v0.3.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
|
||||
@@ -25,6 +25,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8=
|
||||
github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
@@ -64,8 +66,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user