Compare commits

..
11 Commits
Author SHA1 Message Date
rouggy 38da904f4b chore: release v0.27.18 2026-09-08 00:25:27 +02:00
rouggy b7d1eddfa0 fix(entry): the two report boxes stop moving as well
Once the callsign stopped giving up its width, the RST boxes were the
next widest things in the row and became the ones that shifted when the
date field appeared. Both are now a notch narrower and shrink-0, like the
callsign; "59+20" is the longest report either ever holds and still fits.
2026-09-08 00:24:15 +02:00
rouggy 7208efbba6 fix(entry): the callsign box stops moving when the padlock closes
Its row gains a date field for a manual entry, and a flex row makes room
by shrinking its children — so the widest box, which is the one the eye
is on while typing, was the one that visibly narrowed.

A notch narrower and shrink-0: the size it will always be, with or
without the date, and the slack now comes from the boxes beside it.
2026-09-08 00:21:12 +02:00
rouggy 2414b2077f chore(awards): log a successful bulk recompute, not only a failed one
"I changed an award and the column stayed empty" cannot be answered
without knowing whether the recompute ran at all, how long it took, and
whether it changed anything — and zero rows changed is a real answer
(the definition matches nothing in the log) that looks exactly like a
recompute which never happened.
2026-09-08 00:19:12 +02:00
rouggy 1cbf3ecc4b perf(settings): every field in Preferences types into itself first
Preferences is one component holding two hundred pieces of state, and its
biggest panels are eight hundred lines of form — external services, CAT.
A plain controlled input sends every keystroke into that state, so every
character re-rendered the whole dialog, and the letter arrived after the
finger had left the key.

BufferedInput keeps the text where it is typed and hands it up 120 ms
later. A drop-in for Input, so the change is one import rather than a
hundred call sites — which means it has to be right in every shape those
call sites take:

  • Blur flushes at once, so clicking Save cannot lose the last word, and
    so does unmounting: a panel changed mid-word still hands up what was
    there.
  • A value that comes back DIFFERENT from what was sent up is adopted,
    even with the focus in the field. That is what keeps the fields that
    normalise as you type working — the callsign box that upper-cases,
    the port box that drops everything but digits. They echo a corrected
    value, and the correction wins.
  • A value changed from outside while the field is idle wins too: that
    is how loading the settings, or switching profile, refills the form.
  • Checkboxes, colour pickers and file fields pass straight through.
2026-09-08 00:15:44 +02:00
rouggy ccae8599eb docs(changelog): the padlock and what followed it are 0.27.18
0.27.17 went out, so everything committed after its release commit needs
a block of its own — the padlock, the duplicated section name, the
digital row of the matrix and the MQTT chip.

The backdrop-blur fix goes with them rather than staying in 0.27.17. It
had been folded into that version's typing-lag entry, but it is not in
the binary 0.27.17 shipped: what that release actually carries is the
cluster-macro half, which is what its entry now says again.
2026-09-08 00:12:18 +02:00
rouggy cdd91ab7e6 fix(ui): one section name, a chosen digital row, no MQTT chip
Three things reported together.

Preferences said the section name twice: a small line above every panel
repeated the heading right underneath it — "GENERAL" over "General" —
while the sidebar next to them already shows which section is open,
highlighted. Two lines and a highlight for one fact; the small line goes.

The band matrix opens on the digital row the operator chooses. Its
digital row has always rotated — DIGI, then each digital mode in their
own list — but it always STARTED on DIGI, so somebody who only works FT8
clicked through to their own mode on every callsign. Settings ▸ General
now says where the rotation starts, and the dropdown offers exactly the
modes the matrix rotates through (the phone-mode rule is now shared
rather than copied, so the two cannot drift). DIGI stays the default: it
is the right answer for anyone working several digital modes.

And the MQTT chip is gone from the status bar. That is the name of a
message protocol, not of anything an operator has — a chip in the status
bar has to say what it is about, and this one told nobody anything. What
it carried is shown in the Chase New panel, which is the place that uses
it; its poller went with it.
2026-09-08 00:10:06 +02:00
rouggy 5b7469ae44 feat(entry): one padlock instead of five
Logging a contact from a piece of paper — a contest sheet, a friend's
report, a QSO worked on another radio — means the frequency, the band,
the mode, the date and both times all have to stop following the rig and
the clock at once. That is a single decision, and it was five clicks in
five different places, each of which had to be found first.

The padlock beside Start UTC now holds all five and releases all five,
with the same behaviour as before: locking pre-fills the times so the
fields are not empty, unlocking hands them back to the clock and snaps a
QSO already in progress to now.

The five per-field locks stay underneath. Everything downstream reads
them, and individually they say the right thing — "this value is
decoupled from the rig". Only the control is one.
2026-09-08 00:04:56 +02:00
rouggy 3c7ea2d894 perf(ui): the dialogs you type in no longer sit on a blurred backdrop
A backdrop-filter covers the whole window and is recomputed every time
anything above it repaints. Behind these dialogs is an application that
never stops moving — CAT polls four times a second, spots arrive, meters
sweep, maps redraw — so the filter was being recomputed continuously, and
each keystroke's repaint dragged a full-window blur with it. That is why
the lag was felt in Preferences and nowhere else, and why fixing the
cluster macros did not end it.

Worse in one place: the cluster server editor opens FROM Preferences, so
its overlay was the SECOND full-window filter stacked over the first.
That is exactly where the delay was first reported.

The dialogs an operator types in for minutes — Preferences, the cluster
editor, the QSO editor, bulk edit, alert rules, award definitions — now
dim the background harder instead of blurring it. Everything else keeps
the blur: a confirmation you click through in a second costs nothing.
2026-09-08 00:00:47 +02:00
rouggy 55fbfa4499 chore: release v0.27.17 2026-09-07 23:56:40 +02:00
rouggy b655cd9631 feat(station): the radio and both keyers on the Station Control tab
This tab began as the relay and rotator dashboard and stopped there: the
three things an operator touches most — the radio, the CW keyer, the
voice keyer — were the ones missing from the page that claims to show the
station.

The radio card carries the frequency and the mode large, because that is
what gets glanced at, and the split pair only when there IS a split: a
second frequency shown at all times is one more number to read past. When
CAT is down it says which kind of down — switched off, or on and not
answering.

The CW keyer card carries the speed, which is the control an operator
reaches for mid-QSO when a station comes back faster than expected, and
Stop beside it because a message going to the wrong callsign has to end
now. The voice keyer card carries the recorded messages themselves: a
card that only said "idle" would be a light, not a control.

Each polls its own binding and holds its own state, like the supply card
above them, so they drop into the grid and reorder with everything else.
The two keyers appear only when there is something behind them — a port
configured, a message actually recorded — because an operator who works
neither should not be handed two dead cards. That question is asked once
on opening the tab: a keyer is bought and wired, not something that
appears mid-session.
2026-09-07 23:48:10 +02:00
16 changed files with 493 additions and 92 deletions
+8
View File
@@ -5612,11 +5612,19 @@ func (a *App) recomputeAwardRefsForCodesAsync(codes []string) {
// definition / reference-list change, or a bulk ADIF import.
func (a *App) recomputeAwardRefsAsync() {
go func() {
t0 := time.Now()
n, err := a.RecomputeAllAwardRefs()
if err != nil {
applog.Printf("award_refs: bulk recompute failed: %v", err)
return
}
// Logged on SUCCESS too, and this is why: "I changed an award and the
// column stayed empty" is unanswerable without knowing whether the
// recompute ran at all, how long it took, and whether it changed
// anything. Zero rows changed is a real answer — the definition matches
// nothing in the log — and it looks exactly like a recompute that never
// happened.
applog.Printf("award_refs: bulk recompute done — %d rows changed in %s", n, time.Since(t0).Round(time.Millisecond))
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
}
+24 -2
View File
@@ -1,4 +1,24 @@
[
{
"version": "0.27.18",
"date": "",
"en": [
"The dialogs you type in no longer sit on a blurred backdrop. A backdrop filter covers the whole window and is recomputed every time anything above it repaints — and behind these dialogs is an application that never stops moving: CAT polling four times a second, spots arriving, meters sweeping, maps redrawing. Worse in one place: the cluster editor opens from Preferences, so its overlay was a second full-window filter stacked over the first. Preferences, the cluster editor, the QSO editor, bulk edit, alert rules and award definitions now dim the background instead of blurring it; everything else keeps the blur.",
"One padlock on the entry form instead of five. Logging a contact from paper — a contest sheet, a friend's report, a QSO worked on another radio — means the frequency, the band, the mode, the date and both times all have to stop following the rig and the clock at once. That was five clicks in five different places, each of which had to be found first. The padlock beside Start UTC now holds all of them, and releases all of them.",
"Preferences no longer says the section name twice — the small line above each panel repeated the heading right under it, and the sidebar beside it already shows which section is open.",
"The band matrix can open on the digital mode you actually work. An operator who only ever does FT8 was shown DIGI every time and had to click through to their own mode on every callsign; Settings → General now chooses which digital row the matrix starts on. The row still rotates when you click it, and DIGI — all of them together — stays the default.",
"The MQTT chip is gone from the status bar. That is the name of a message protocol, not of anything an operator has. The state it carried — the openings feed up or down, and how many reports have arrived — is in the Chase New panel, which is the place that uses it.",
"The callsign box no longer narrows when you close the padlock. Its row gains a date field for a manual entry, and a flex row makes room by shrinking its children — so the widest box, the one the eye is on while typing, was the one that visibly moved. The callsign and both report boxes are now a notch narrower and fixed there, whether the date is showing or not."
],
"fr": [
"Les dialogues dans lesquels on tape ne reposent plus sur un fond flouté. Un filtre de fond couvre toute la fenêtre et est recalculé chaque fois que quoi que ce soit au-dessus se repeint — et derrière ces dialogues il y a une application qui ne s'arrête jamais de bouger : le CAT qui interroge quatre fois par seconde, les spots qui arrivent, les vumètres qui balaient, les cartes qui se redessinent. Pire à un endroit : l'éditeur de cluster s'ouvre depuis les Préférences, donc son fond était un deuxième filtre plein écran empilé sur le premier. Les Préférences, l'éditeur de cluster, l'éditeur de QSO, l'édition groupée, les règles d'alerte et les définitions de diplômes assombrissent désormais le fond au lieu de le flouter ; tout le reste garde le flou.",
"Un seul cadenas dans la saisie au lieu de cinq. Enregistrer un contact depuis une feuille — un carnet de concours, le report d'un ami, un QSO fait sur une autre radio — suppose que la fréquence, la bande, le mode, la date et les deux heures cessent tous en même temps de suivre le poste et l'horloge. C'étaient cinq clics à cinq endroits différents, qu'il fallait d'abord trouver. Le cadenas à côté de Début UTC les fige maintenant tous, et les libère tous.",
"Les Préférences ne disent plus deux fois le nom de la section — la petite ligne au-dessus de chaque panneau répétait le titre juste en dessous, et la barre latérale montre déjà laquelle est ouverte.",
"La matrice peut s'ouvrir sur le mode numérique que vous travaillez vraiment. Celui qui ne fait que du FT8 voyait DIGI à chaque fois et devait cliquer jusqu'à son mode pour chaque indicatif ; Réglages → Général choisit désormais la ligne numérique sur laquelle la matrice démarre. La ligne continue de tourner au clic, et DIGI — tous ensemble — reste le défaut.",
"La pastille MQTT disparaît de la barre d'état. C'est le nom d'un protocole de messages, pas de quelque chose que possède un opérateur. Ce qu'elle indiquait — le flux d'ouvertures actif ou non, et le nombre de reports arrivés — est dans le panneau Chasse au nouveau, à l'endroit qui s'en sert.",
"Le champ indicatif ne rétrécit plus quand on ferme le cadenas. Sa ligne gagne un champ date pour une saisie manuelle, et une ligne flex fait de la place en rétrécissant ses enfants — donc le plus large, celui que l'œil suit pendant la frappe, était celui qui bougeait visiblement. L'indicatif et les deux champs de report sont désormais un cran plus étroits et fixes, que la date soit affichée ou non."
]
},
{
"version": "0.27.17",
"date": "",
@@ -8,7 +28,8 @@
"Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged.",
"The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero.",
"The cluster editor offers a list of known nodes. Setting up a telnet cluster is the step operators get stuck on: the address and the port are two pieces of information nobody has to hand, and a typo in either looks exactly like a node that is down. Pick one and the fields fill in — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, and the two Reverse Beacon feeds, which are one network on two ports where 7000 carries CW and RTTY and 7001 carries FT8 and FT4. Everything stays editable, and a node typed in by hand works exactly the same. More will be added.",
"Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop."
"Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop.",
"Station Control shows what commands the station, not only what it switches. The radio is there now — frequency, mode, band, and the split pair when there is one — with the CW keyer beside it (speed up and down, and Stop, because a message going to the wrong callsign has to end now) and the voice keyer with its recorded messages as buttons, so a CQ goes out without leaving the tab. The two keyers appear only when there is something behind them: a port configured, or a message actually recorded. All three move and reorder with the other cards."
],
"fr": [
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) permet de travailler les satellites amateurs de bout en bout. Une carte avec l'empreinte de chacun et la trace au sol du satellite sélectionné ; une vue du ciel comme la dessine n'importe quel tracker, centre à la verticale et bord à l'horizon, avec le passage entier et la position du satellite dessus ; un compte à rebours jusqu'à l'AOS — ou jusqu'au LOS une fois levé — avec lever, culmination et coucher, leurs directions à la boussole, distance, altitude et empreinte ; et un tableau des passages de tout ce que vous suivez.\n\n« Suivre » met la radio sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quel autre poste sur la descente seule, ce qu'il annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Un rotor az/él suit aussi, piloté directement en EasyComm II ou confié à PstRotator si vous le faites déjà tourner ; un rotor 450° est utilisé comme tel, et un passage qui traverse le nord continue au lieu de se dérouler.\n\nLes QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.\n\nLes éléments orbitaux viennent de Celestrak, avec un miroir derrière, et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. Vingt-cinq satellites sont livrés avec un plan de fréquences — les FM et les linéaires, GreenCube, QO-100 bande étroite et large — dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode. Toute la configuration est dans Réglages → Satellites, y compris le choix des satellites suivis, sélectionnés comme on choisit ses diplômes.",
@@ -16,7 +37,8 @@
"Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré.",
"Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle.",
"L'éditeur de cluster propose une liste de nœuds connus. La configuration d'un cluster telnet est l'étape où l'on se bloque : l'adresse et le port sont deux informations que personne n'a sous la main, et une faute de frappe dans l'une ou l'autre ressemble exactement à un nœud en panne. On en choisit un et les champs se remplissent — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, et les deux flux Reverse Beacon, qui sont un même réseau sur deux ports où 7000 porte la CW et le RTTY et 7001 le FT8 et le FT4. Tout reste modifiable, et un nœud saisi à la main fonctionne exactement pareil. D'autres seront ajoutés.",
"Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie."
"Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie.",
"Contrôle station montre ce qui commande la station, et plus seulement ce qui la commute. La radio y figure désormais — fréquence, mode, bande, et le couple split quand il y en a un — avec à côté le manipulateur CW (vitesse en plus ou en moins, et Stop, parce qu'un message parti vers le mauvais indicatif doit s'arrêter tout de suite) et le manipulateur vocal avec ses messages enregistrés en boutons, pour lancer un CQ sans quitter l'onglet. Les deux manipulateurs n'apparaissent que s'il y a quelque chose derrière : un port configuré, ou un message réellement enregistré. Les trois se déplacent et se réordonnent avec les autres cartes."
]
},
{
+57 -65
View File
@@ -52,7 +52,7 @@ import {
ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
} from '../wailsjs/go/main/App';
@@ -566,7 +566,9 @@ function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle
type="button"
tabIndex={-1}
onClick={onToggle}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
// The whole tooltip, not a verb glued to a noun: the caller knows what
// this padlock does and can say it in the operator's own language.
title={title}
className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
@@ -618,31 +620,34 @@ export default function App() {
});
const locksRef = useRef(locks);
useEffect(() => { locksRef.current = locks; }, [locks]);
const toggleLock = (k: LockKey) => {
setLocks((s) => {
const wasLocked = s[k];
const next = { ...s, [k]: !wasLocked };
if (wasLocked) {
// Unlocking → restore automatic behavior. Without this the locked
// value would linger forever: a stale Start time would never refresh
// even after a new callsign is entered.
if (k === 'start') {
// If a QSO is currently in progress (callsign typed), snap start
// to now since we missed the auto-start moment. Otherwise clear.
setQsoStartedAt(callsign.trim() ? new Date() : null);
} else if (k === 'end') {
// Drop the frozen end so the field tracks the live UTC clock.
setQsoEndedAt(null);
}
} else {
// Locking (manual / deferred entry) → pre-fill with today's date + the
// current UTC time so the fields aren't empty; the operator just adjusts.
const now = new Date();
if (k === 'start') setQsoStartedAt((d) => d ?? now);
else if (k === 'end') setQsoEndedAt((d) => d ?? now);
}
return next;
});
// ONE padlock, not five.
//
// Logging a contact from a piece of paper — a contest sheet, a friend's
// report, a QSO worked on another radio — means the frequency, the band, the
// mode, the date and both times all have to stop following the rig and the
// clock at once. That is a single decision, and it used to be five clicks in
// five different places, each of which had to be found first.
//
// The five per-field locks stay underneath, because everything downstream
// reads them and they say the right thing individually ("this value is
// decoupled from the rig"). Only the control is one.
const manualEntry = locks.start && locks.end && locks.band && locks.mode && locks.freq;
const setManualEntry = (on: boolean) => {
setLocks({ band: on, mode: on, freq: on, start: on, end: on });
if (on) {
// Pre-filled with today's date and the current UTC time so the fields are
// not empty; the operator only has to correct them.
const now = new Date();
setQsoStartedAt((d) => d ?? now);
setQsoEndedAt((d) => d ?? now);
} else {
// Back to automatic. Without this the frozen values would linger for
// ever: a start time held from a backdated entry would never refresh,
// even after a new callsign is typed. A QSO already in progress snaps its
// start to now, since the moment it would have been taken has passed.
setQsoStartedAt(callsign.trim() ? new Date() : null);
setQsoEndedAt(null);
}
};
const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB');
@@ -2354,16 +2359,6 @@ export default function App() {
return () => window.clearInterval(t);
}, []);
// PSK Reporter feed, for the status-bar chip. Polled slowly: the chip only
// says up or down, and the count behind it is a tooltip.
const [pskr, setPskr] = useState<any>(null);
useEffect(() => {
const load = () => { GetPSKReporterStatus().then(setPskr).catch(() => {}); };
load();
const t = window.setInterval(load, 10000);
return () => window.clearInterval(t);
}, []);
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
// offline. Publishing is always on for a shared MySQL logbook (no user toggle:
@@ -5421,8 +5416,15 @@ export default function App() {
// "59+30" turned up, which is five characters plus its padding and no longer
// fitted. The RST fields are back to their original width; the callsign keeps
// the rest of the row.
// shrink-0, and a notch narrower than it used to be.
//
// The row it sits in gains a date field when the padlock is closed, and a
// flex row makes room by shrinking its children — so the callsign box, the
// widest of them, visibly narrowed the moment the operator started a manual
// entry. The field the eye is on while typing must not move. It is now the
// size it will always be, and the slack comes from the boxes beside it.
const callsignBlock = (
<div className="flex flex-col w-56" data-esm="call">
<div className="flex flex-col w-52 shrink-0" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
<span className="text-primary font-semibold">{t('field.callsign')}</span>
{lookupBusy && (
@@ -5568,8 +5570,12 @@ export default function App() {
</div>
</div>
);
// Both report boxes: a notch narrower and pinned, for the same reason as the
// callsign. They were the next widest things in the row, so once the callsign
// stopped giving, they were the ones that moved when the date appeared.
// "59+20" is the longest report either ever holds and still fits.
const rstTxBlock = (
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
{/* The wheel steps the report an S-unit on RST, a decibel on a digital
one. Wheeling is the same gesture as saying "he is a bit stronger than
that", and it beats retyping three characters between overs. */}
@@ -5579,7 +5585,7 @@ export default function App() {
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
@@ -5615,7 +5621,7 @@ export default function App() {
) : null;
const startBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={manualEntry} title={manualEntry ? t('field.manualEntryOff') : t('field.manualEntryOn')} onToggle={() => setManualEntry(!manualEntry)} /></Label>
<Input
readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1}
@@ -5634,7 +5640,7 @@ export default function App() {
);
const endBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')}</Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
@@ -5905,7 +5911,7 @@ export default function App() {
// used in the full layout to save vertical height.
const bandRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')}</Label>
<div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -5916,7 +5922,7 @@ export default function App() {
);
const modeRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')}</Label>
<div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -6014,7 +6020,7 @@ export default function App() {
};
const freqBlock = (
<div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')}</Label>
<Input
tabIndex={-1}
className="font-mono"
@@ -9131,26 +9137,12 @@ export default function App() {
</button>
);
})}
{/* PSK Reporter, next to the hardware chips because it is the same
kind of fact: a link that is either up or it is not. Shown ONLY
when the opening watch is on a permanently grey chip for a
feature nobody enabled is clutter, and the bar is 28 px.
The decode count is in the tooltip rather than the chip: it moves
several times a second on an open band, and a number flickering in
the corner of the eye is not information, it is a distraction. */}
{pskr?.running && (
<button
type="button"
title={t('pskr.tip', { n: pskr.received ?? 0, bands: (pskr.bands ?? []).join(' ') })}
onClick={() => { setSettingsSection('cluster'); setShowSettings(true); }}
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer shrink-0"
>
<span className={cn('size-2 rounded-full',
(pskr.received ?? 0) > 0 ? 'bg-success' : 'bg-warning')} />
MQTT
</button>
)}
{/* The PSK Reporter chip used to sit here, labelled MQTT. That is
the name of a message protocol, not of anything an operator has:
a chip in the status bar has to say what it is about, and this
one told nobody anything. The state it carried the openings
feed up or down, and how many reports have arrived is shown in
the Chase New panel, which is the place that uses it. */}
{/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY
logbook backend (only the live_status PUBLISHING is MySQL-specific),
so it is always shown. Gating it on MySQL made it vanish for
+1 -1
View File
@@ -141,7 +141,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogContent overlayBlur={false} className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
<DialogDescription>{t('altm.desc')}</DialogDescription>
+1 -1
View File
@@ -403,7 +403,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent overlayBlur={false} 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>
+15 -1
View File
@@ -60,7 +60,11 @@ const DEFAULT_BANDS: { tag: string; label: string }[] = [
];
const CLASSES = ['PH', 'CW', 'DIG'] as const;
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
export const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
// Which digital row the matrix opens on. Empty = DIG, the group of them all.
// Set in Settings ▸ General; see the rotation below.
export const MATRIX_DIGI_KEY = 'opslog.matrixDigiMode';
function classMatchesMode(cls: string, mode: string): boolean {
const u = (mode || '').toUpperCase();
if (cls === 'PH') return PHONE_MODES.has(u);
@@ -143,7 +147,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes,
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
[modes],
);
// Where the rotation STARTS. An operator who only ever works FT8 was shown
// "DIG" every time and had to click to the mode they actually use, on every
// callsign — so the row they want is the one it opens on. Empty (the default)
// keeps DIG, which is right for anyone working several digital modes.
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
useEffect(() => {
const want = (localStorage.getItem(MATRIX_DIGI_KEY) || '').toUpperCase().trim();
if (!want) { setDigIdx(0); return; }
const i = digModes.indexOf(want);
setDigIdx(i >= 0 ? i + 1 : 0);
}, [digModes]);
// A shorter mode list (the operator edited it) must not strand the rotation
// on a row that no longer exists.
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
+1 -1
View File
@@ -193,7 +193,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogContent overlayBlur={false} className="max-w-md">
<DialogHeader>
<DialogTitle>{t('bulk.title')}</DialogTitle>
<DialogDescription>
+1 -1
View File
@@ -542,7 +542,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return (
<Dialog 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 overlayBlur={false} 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>
+48 -7
View File
@@ -74,7 +74,11 @@ import {
} from '@/components/ui/dialog';
import { WebPublishPanel } from '@/components/WebPublishPanel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
// Every text field in Preferences types into itself and hands the value up a
// moment later. This dialog is one component with two hundred pieces of state
// and panels eight hundred lines long, so a plain controlled input re-rendered
// the whole thing per character — see BufferedInput.
import { BufferedInput as Input } from '@/components/ui/buffered-input';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -95,6 +99,7 @@ import { AppearancePanel } from '@/components/AppearancePanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
import { CLUSTER_PRESETS } from '@/lib/clusterPresets';
import { MATRIX_DIGI_KEY, PHONE_MODES as MATRIX_PHONE_MODES } from '@/components/BandSlotGrid';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
@@ -2007,6 +2012,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
// The digital row the band matrix opens on: '' = DIG, the group of them all.
const [matrixDigi, setMatrixDigi] = useState(() => localStorage.getItem(MATRIX_DIGI_KEY) || '');
// The operator's own digital modes, which is what the matrix rotates through
// — the same rule it uses: everything in their mode list that is neither CW
// nor a phone mode.
const digitalModeNames = useMemo(
() => (lists.modes ?? [])
.map((m: any) => String(m?.name ?? '').toUpperCase().trim())
.filter((m) => m && m !== 'CW' && !MATRIX_PHONE_MODES.has(m)),
[lists.modes],
);
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
@@ -2812,7 +2828,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
}
}
const breadcrumb = useMemo(() => { const k = SECTION_KEY[selected]; return k ? t(k) : (SECTION_LABELS[selected] ?? selected); }, [selected, t]);
// === Section content renderers ===
@@ -8106,6 +8121,24 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
<span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
</label>
{/* Which digital row the band matrix opens on. An operator who works
only FT8 was shown DIG every time and had to click through to the
mode they actually use, on every callsign. The row still rotates
this only says where it starts. */}
<div className="flex items-center gap-2 text-sm">
<span title={t('gen.matrixDigiHint')}>{t('gen.matrixDigi')}</span>
<Select value={matrixDigi || '_'} onValueChange={(v) => {
const next = v === '_' ? '' : v;
setMatrixDigi(next);
writeUiPref(MATRIX_DIGI_KEY, next);
}}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">DIGI</SelectItem>
{digitalModeNames.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
{/* Distances are computed in km everywhere and converted at display
time see lib/units. Changing this repaints the columns that
@@ -8858,7 +8891,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
{/* No backdrop blur. This dialog is open for minutes with the operator
typing in it, and the application behind it never stops repainting
a full-window backdrop filter is then recomputed under every one of
those repaints, which is what the delay between key and letter was. */}
<DialogContent overlayBlur={false} className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader>
<DialogTitle>{t('settings.title')}</DialogTitle>
<DialogDescription className="sr-only">Configure OpsLog modules station, lookup, hardware</DialogDescription>
@@ -8874,10 +8911,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</aside>
{/* Right content pane */}
{/* No breadcrumb line. It said the same word as the heading right
underneath it "GENERAL" over "General" and the sidebar
beside it already shows which section is open, highlighted. Two
lines and a highlight for one fact. */}
<div className="overflow-y-auto p-6">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{breadcrumb}
</div>
<PanelHost key={selected} render={PANELS[selected]} />
{err && (
@@ -8971,7 +9009,10 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
(p) => p.host.toLowerCase() === (s.host ?? '').trim().toLowerCase() && p.port === s.port);
return (
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent className="max-w-[640px] px-6">
{/* Opened from Preferences, so its overlay would be the SECOND
full-window backdrop filter stacked over a moving page which is
where the typing delay was first noticed. */}
<DialogContent overlayBlur={false} className="max-w-[640px] px-6">
<DialogHeader className="px-2">
<DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
<DialogDescription className="text-xs">
+205 -1
View File
@@ -1,5 +1,5 @@
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, ChevronUp, ChevronDown } from 'lucide-react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown, Radio, Zap, Mic } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -25,6 +25,9 @@ import {
GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
GetPSUStatus, GetPSUSettings, SetPSUOutput,
GetCATState,
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -82,6 +85,181 @@ function PSUCard({ st, busy, onToggle, t }: {
);
}
// ── What commands the station, and not only what it switches ───────────────
//
// This tab began as the relay and rotator dashboard, and stopped there: the
// three things an operator touches most — the radio, the CW keyer and the voice
// keyer — were the ones missing from the page that claims to show the station.
//
// Each card polls its own binding and holds its own state, like PSUCard above.
// That is deliberate: they can then be dropped into the grid, reordered and
// hidden with everything else, and adding one costs nothing to the panel around
// it. None of them tries to be the full console — a card says what the thing is
// doing and offers the one or two controls worth reaching for from here.
const fmtMHz = (hz: number) => (hz > 0 ? (hz / 1e6).toFixed(6) : '—');
// The radio. The frequency and the mode large, because that is what an operator
// glances at, and the split pair underneath only when there IS a split — a
// second frequency shown at all times is one more number to read past.
function RigCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetCATState().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
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">
<Radio className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{st?.rig || t('station.rig')}</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold tabular-nums leading-none">{fmtMHz(st?.freq_hz ?? 0)}</span>
<span className="text-xs text-muted-foreground">MHz</span>
</div>
<div className="flex items-center gap-2 flex-wrap text-[11px]">
{!!st?.mode && <span className="rounded px-1.5 py-px font-semibold bg-primary/15 text-primary border border-primary/30">{st.mode}</span>}
{!!st?.band && <span className="text-muted-foreground">{st.band}</span>}
{!!st?.vfo && <span className="text-muted-foreground">VFO {st.vfo}</span>}
{!!st?.backend && <span className="ml-auto text-muted-foreground/70 truncate">{st.backend}</span>}
</div>
{st?.split && (
<div className="flex items-center gap-2 text-[11px] tabular-nums">
<span className="rounded px-1.5 py-px font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border">SPLIT</span>
<span className="text-muted-foreground">RX {fmtMHz(st?.freq_rx_hz ?? 0)}</span>
</div>
)}
{!on && (
<div className="text-[11px] text-muted-foreground truncate" title={st?.error || ''}>
{st?.enabled ? (st?.error || t('station.rigDown')) : t('station.rigOff')}
</div>
)}
</div>
</div>
);
}
// The CW keyer. Speed is the control an operator reaches for mid-QSO — a
// station answers faster or slower than expected and the reply has to match —
// so it is here rather than only in the docked panel, and Stop is beside it
// because a message sent to the wrong callsign has to end NOW.
function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetWinkeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
const wpm = st?.wpm || 0;
const step = (d: number) => {
const w = Math.max(5, Math.min(50, wpm + d));
setSt((cur: any) => ({ ...(cur ?? {}), wpm: w })); // shows at once; the poll confirms
WinkeyerSetSpeed(w).catch(() => {});
};
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">
<Zap className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.keyer')}</div>
{st?.busy && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(-1)}>
<Minus className="size-3.5" />
</Button>
<div className="flex items-baseline gap-1">
<span className="text-xl font-semibold tabular-nums leading-none">{wpm || '—'}</span>
<span className="text-xs text-muted-foreground">WPM</span>
</div>
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(1)}>
<Plus className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="ml-auto h-7 px-2" disabled={!on || !st?.busy}
onClick={() => WinkeyerStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{st?.port || t('station.noPort')}</span>
{!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>}
</div>
{!on && (
<Button variant="outline" size="sm" className="w-full h-7"
onClick={() => WinkeyerConnect().catch(() => {})}>
{t('station.connect')}
</Button>
)}
</div>
</div>
);
}
// The voice keyer. The messages themselves, because a card that only said
// "idle" would be a light and not a control — from here a CQ goes out without
// leaving the tab.
function VoiceKeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>({ playing: false, recording: false });
const [msgs, setMsgs] = useState<any[]>([]);
useEffect(() => {
let alive = true;
const tick = () => GetDVKStatus().then((s: any) => { if (alive) setSt(s ?? {}); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
// The recordings change when the operator records one, which is rare and
// never from this tab — read once, and again only on a status change worth
// it would be more machinery than it saves.
GetDVKMessages().then((m: any[]) => { if (alive) setMsgs(m ?? []); }).catch(() => {});
return () => { alive = false; window.clearInterval(h); };
}, []);
const recorded = msgs.filter((m) => m.has_audio);
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">
<Mic className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.voiceKeyer')}</div>
{st?.playing && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
{st?.recording && <span className="text-[10px] font-bold text-warning animate-pulse">REC</span>}
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2 text-[11px]"
disabled={!st?.playing} onClick={() => DVKStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="p-3">
{recorded.length === 0 ? (
<div className="text-[11px] text-muted-foreground">{t('station.noVoiceMsg')}</div>
) : (
<div className="flex flex-wrap gap-1.5">
{recorded.map((m) => (
<button key={m.slot} type="button"
onClick={() => DVKPlay(m.slot).catch(() => {})}
disabled={st?.playing}
title={`${m.duration_sec?.toFixed?.(1) ?? ''}s`}
className="rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-40">
<span className="text-muted-foreground mr-1">F{m.slot}</span>
{m.label || `#${m.slot}`}
</button>
))}
</div>
)}
</div>
</div>
);
}
type Device = {
id: string; type: string; name: string; host: string;
user?: string; pass?: string; channels?: number; labels: string[];
@@ -317,6 +495,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
}, [poll, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Whether the two keyers exist at this station. Asked ONCE, on opening the
// tab: a keyer is bought, wired and configured, not something that appears
// mid-session, and polling for the answer would be a round trip a second for
// a fact that does not change. A keyer counts as present when it is connected
// or a port is configured for it, the voice keyer when at least one message
// has actually been recorded — an empty set of slots is not a keyer.
const [keyerShown, setKeyerShown] = useState(false);
const [dvkShown, setDvkShown] = useState(false);
useEffect(() => {
let alive = true;
GetWinkeyerStatus().then((s: any) => {
if (alive) setKeyerShown(!!s && (!!s.connected || !!String(s.port ?? '').trim()));
}).catch(() => {});
GetDVKMessages().then((m: any[]) => {
if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio));
}).catch(() => {});
return () => { alive = false; };
}, []);
// Reorder so `dragged` lands just before `target`.
const onDrop = (targetId: string) => {
const src = dragId.current; dragId.current = null;
@@ -419,6 +616,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// 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 }[] = [];
// The radio first: it is the station, and everything else on this page is
// something attached to it. Then the two keyers, each only when there is
// something behind it — an operator who works neither CW nor voice keyer
// should not be given two dead cards to read past.
widgets.push({ id: 'rig', node: <RigCard t={t} /> });
if (keyerShown) widgets.push({ id: 'keyer', node: <KeyerCard t={t} /> });
if (dvkShown) widgets.push({ id: 'dvk', node: <VoiceKeyerCard t={t} />, wide: true });
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
@@ -0,0 +1,102 @@
import * as React from 'react';
import { Input } from '@/components/ui/input';
// A text field that types into itself first.
//
// Preferences is one component holding two hundred pieces of state, and its
// biggest panels are eight hundred lines of form. A plain controlled input
// sends every keystroke into that state, so every character re-renders the
// whole dialog — the external-services panel, the CAT panel — and the letter
// appears after the finger has left the key.
//
// This keeps the text where it is being typed and hands it up shortly after.
// The value shown is the operator's, immediately; the parent's copy catches up
// a moment later, which is soon enough for anything that reads it — nothing in
// a settings form acts on a half-typed word.
//
// It is a drop-in for Input, on purpose: the fix is a changed import, not a
// hundred edited call sites. Which means it has to behave correctly in every
// shape those call sites take:
//
// • Blur flushes at once, so clicking Save cannot lose the last word typed,
// and so does unmounting — a panel changed mid-word still hands up what
// was there.
// • A value that comes back DIFFERENT from what was sent up is adopted, even
// while the field has focus. That is how the fields which normalise as you
// type keep working: a callsign box that upper-cases, a port box that
// drops everything but digits. They echo a corrected value, and the
// correction wins.
// • A value changed from outside while the field is idle wins too — that is
// how loading the settings, or switching profile, refills the form.
// • Types that are not text — checkbox, colour, file — pass straight
// through. There is no typing to buffer and their events are not text.
const PASSTHROUGH = new Set(['checkbox', 'radio', 'file', 'color', 'range', 'submit', 'button', 'image', 'reset']);
// Short enough that a normalising field corrects itself while the operator is
// still on the same word, long enough that a burst of typing is one render.
const DEBOUNCE_MS = 120;
export const BufferedInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ value, onChange, onBlur, onFocus, type, ...props }, ref) => {
const buffered = value !== undefined && !!onChange && !PASSTHROUGH.has(type ?? 'text');
const incoming = String(value ?? '');
const [local, setLocal] = React.useState(incoming);
const focused = React.useRef(false);
const timer = React.useRef<number | undefined>(undefined);
// What we last handed up. Anything else arriving from the parent is the
// parent's own doing — a normalisation, a reload — and it wins.
const emitted = React.useRef(incoming);
const pending = React.useRef<React.ChangeEvent<HTMLInputElement> | null>(null);
const onChangeRef = React.useRef(onChange);
React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
React.useEffect(() => {
if (!focused.current || incoming !== emitted.current) {
setLocal(incoming);
emitted.current = incoming;
}
}, [incoming]);
const flush = React.useCallback(() => {
window.clearTimeout(timer.current);
timer.current = undefined;
const e = pending.current;
pending.current = null;
if (e) {
emitted.current = e.target.value;
onChangeRef.current?.(e);
}
}, []);
// Unmounted mid-word — the panel changed, the dialog closed — still hands
// up what was typed.
React.useEffect(() => () => {
window.clearTimeout(timer.current);
if (pending.current) onChangeRef.current?.(pending.current);
}, []);
if (!buffered) {
return <Input ref={ref} type={type} value={value} onChange={onChange} onBlur={onBlur} onFocus={onFocus} {...props} />;
}
return (
<Input
ref={ref}
type={type}
value={local}
onFocus={(e) => { focused.current = true; onFocus?.(e); }}
onChange={(e) => {
const v = e.target.value;
setLocal(v);
// The element's value changes again before the timer fires, so what
// matters is copied out of it now.
pending.current = { ...e, target: { ...e.target, value: v } } as React.ChangeEvent<HTMLInputElement>;
window.clearTimeout(timer.current);
timer.current = window.setTimeout(flush, DEBOUNCE_MS);
}}
onBlur={(e) => { focused.current = false; flush(); onBlur?.(e); }}
{...props}
/>
);
},
);
BufferedInput.displayName = 'BufferedInput';
+15 -6
View File
@@ -8,14 +8,23 @@ const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
// blur=false drops the backdrop filter and dims harder instead.
//
// A backdrop-filter over the whole window is recomputed every time anything
// above it repaints — and underneath this one sits an application that never
// stops moving: CAT polls four times a second, spots arrive, meters sweep, maps
// redraw. On a long-lived dialog with text fields in it, that shows as a delay
// between the key and the letter. Ornament is not worth a keyboard that feels
// slow, so the dialogs an operator TYPES in for minutes at a time turn it off.
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
>(({ className, blur = true, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
blur ? 'bg-stone-900/40 backdrop-blur-sm' : 'bg-stone-900/60',
className,
)}
{...props}
@@ -25,10 +34,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean; overlayBlur?: boolean }
>(({ className, children, hideClose, hideOverlay, overlayBlur, ...props }, ref) => (
<DialogPortal>
{!hideOverlay && <DialogOverlay />}
{!hideOverlay && <DialogOverlay blur={overlayBlur} />}
<DialogPrimitive.Content
ref={ref}
className={cn(
+12 -4
View File
@@ -72,7 +72,7 @@ const en: Dict = {
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'TX Freq (MHz)', 'field.freqTuneHint': 'Type a frequency and press Enter to tune the radio here.', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
'field.manualEntryOn': 'Manual entry — hold the frequency, band, mode and both times so you can log a contact from paper while the radio stays where it is', 'field.manualEntryOff': 'Back to following the radio and the clock', 'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
@@ -266,6 +266,10 @@ const en: Dict = {
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.typeHttpGen': 'HTTP relay (home-made / generic)', 'station.onPattern': 'ON URL pattern', 'station.offPattern': 'OFF URL pattern', 'station.insecureTls': 'Accept a self-signed certificate', 'station.insecureTlsHint': 'A relay board on your own network signs its own certificate, which nothing can verify. Leave this off for a board reached over the internet through a proxy: there the certificate is real, and checking it is what protects the link.', 'station.patternHint': 'Optional, http or https. {relay} is the relay number — {relay-1} if the board counts from zero. {value} is that relay\'s label below, so …/relay?on={value} with relay 1 named Ant1 sends …/relay?on=Ant1. Leave both blank if every relay has its own full URL.', 'station.perRelayUrls': 'Per-relay URLs (optional)', 'station.perRelayHint': 'Filled in here, these win over the patterns — for a switch whose channels have nothing in common. {relay} and {value} work here too. State is remembered, not read back: after a restart every relay is re-commanded once.', 'station.onUrlPh': 'ON URL', 'station.offUrlPh': 'OFF URL',
'station.valueNeedsLabels': 'A URL above uses {value}, which sends the relays label — name every relay you switch that way, or its URL goes out with an empty value.',
'station.rig': 'Radio', 'station.keyer': 'CW keyer', 'station.voiceKeyer': 'Voice keyer',
'station.rigDown': 'CAT is on but the radio is not answering',
'station.rigOff': 'CAT is switched off', 'station.noPort': 'no port configured',
'station.connect': 'Connect', 'station.noVoiceMsg': 'No message recorded — Settings ▸ Audio.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Motorised antenna · click to show', 'station.motorWidgetHide': 'Motorised antenna — shown · click to hide', 'station.hideWidget': 'Hide this widget', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the boards HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
@@ -304,7 +308,7 @@ const en: Dict = {
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.linked': 'Amplifiers commanded together', 'amp.linkedHint': 'Tick the ones sharing a combiner: ON, OFF and OPERATE will act on all of them at once, since one left in STANDBY would feed power to a single input. Any amplifier left unticked keeps its own buttons. Each keeps its own meters.', 'amp.linkedSaveFirst': 'Save first — an amplifier needs an id before it can join a group.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.password': 'Remote code', 'amp.passwordPh': 'blank on LAN', 'amp.passwordHint': 'PowerGenius XL only: needed when reaching the amp remotely — it then announces "AUTH" and rejects every command ("Unauthorized") until you log in. Leave blank on the local network.', 'amp.kpaBandFollow': 'Keep the amplifier on the radios band', 'amp.kpaBandFollowHint': 'Sends the band number when it changes, on the link already open — no second serial port. Turn it off if the amplifier is wired straight to the radio and takes its band from there.', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an Acom) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (Acom) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
'gen.matrixDigi': 'Digital row in the band matrix', 'gen.matrixDigiHint': 'Which digital row the matrix opens on. The row still rotates when you click it — this only says where it starts. DIGI is every digital mode together.', 'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
// Password encryption
'gen.pwEnc': 'Password encryption',
'gen.pwEncrypted': 'Passwords encrypted', 'gen.pwUnlocked': '— unlocked', 'gen.pwLocked': '— locked (unlock at launch)',
@@ -687,7 +691,7 @@ const fr: Dict = {
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'Fréq TX (MHz)', 'field.freqTuneHint': 'Tape une fréquence et appuie sur Entrée pour y accorder la radio.', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'field.manualEntryOn': 'Saisie manuelle — fige la fréquence, la bande, le mode et les deux heures, pour enregistrer un contact depuis une feuille pendant que la radio reste où elle est', 'field.manualEntryOff': 'Revenir au suivi de la radio et de lhorloge', 'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.newTag': 'Nouveau', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
@@ -880,6 +884,10 @@ const fr: Dict = {
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.typeHttpGen': 'Relais HTTP (fait main / générique)', 'station.onPattern': 'Modèle dURL ON', 'station.offPattern': 'Modèle dURL OFF', 'station.insecureTls': 'Accepter un certificat auto-signé', 'station.insecureTlsHint': 'Une carte relais sur ton propre réseau signe elle-même son certificat, que rien ne peut vérifier. Laisse décoché pour une carte atteinte par internet à travers un proxy : là le certificat est réel, et le vérifier est ce qui protège la liaison.', 'station.patternHint': 'Optionnel, http ou https. {relay} est le numéro du relais — {relay-1} si la carte compte à partir de zéro. {value} est le libellé de ce relais ci-dessous : …/relay?on={value} avec le relais 1 nommé Ant1 envoie …/relay?on=Ant1. Laisse les deux vides si chaque relais a sa propre URL complète.', 'station.perRelayUrls': 'URL par relais (optionnel)', 'station.perRelayHint': 'Renseignées ici, elles lemportent sur les modèles — pour un commutateur dont les voies nont rien en commun. {relay} et {value} fonctionnent aussi ici. L’état est mémorisé, pas relu : après un redémarrage chaque relais est recommandé une fois.', 'station.onUrlPh': 'URL ON', 'station.offUrlPh': 'URL OFF',
'station.valueNeedsLabels': 'Une URL ci-dessus utilise {value}, qui envoie le libellé du relais — nomme chaque relais commuté ainsi, sinon son URL part avec une valeur vide.',
'station.rig': 'Radio', 'station.keyer': 'Manipulateur CW', 'station.voiceKeyer': 'Manipulateur vocal',
'station.rigDown': 'le CAT est actif mais la radio ne répond pas',
'station.rigOff': 'le CAT est désactivé', 'station.noPort': 'aucun port configuré',
'station.connect': 'Connecter', 'station.noVoiceMsg': 'Aucun message enregistré — Réglages ▸ Audio.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Antenne motorisée · cliquer pour afficher', 'station.motorWidgetHide': 'Antenne motorisée — affichée · cliquer pour masquer', 'station.hideWidget': 'Masquer ce widget', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
'awards.followHint': 'Diplômes affichés dans longlet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour linstant — longlet Awards les montre tous.',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
@@ -918,7 +926,7 @@ const fr: Dict = {
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.linked': 'Amplificateurs commandés ensemble', 'amp.linkedHint': "Coche ceux qui partagent un combiner : ON, OFF et OPERATE agiront sur tous à la fois, puisqu'un ampli resté en STANDBY n'alimenterait qu'une seule entrée. Un amplificateur non coché garde ses propres boutons. Chacun garde ses mesures.", 'amp.linkedSaveFirst': "Enregistre d'abord — un amplificateur a besoin d'un identifiant pour rejoindre un groupe.", 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.password': 'Code distant', 'amp.passwordPh': 'vide en LAN', 'amp.passwordHint': "PowerGenius XL uniquement : nécessaire pour joindre l'ampli à distance — il annonce alors « AUTH » et refuse toute commande (« Unauthorized ») tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'amp.kpaBandFollow': "Garder l'amplificateur sur la bande de la radio", 'amp.kpaBandFollowHint': "Envoie le numéro de bande quand elle change, sur la liaison déjà ouverte — pas de second port série. À désactiver si l'amplificateur est câblé directement à la radio et prend sa bande de là.", 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un Acom) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (Acom) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
'gen.matrixDigi': 'Ligne numérique de la matrice', 'gen.matrixDigiHint': 'Sur quelle ligne numérique la matrice souvre. La ligne continue de tourner quand on clique dessus — ceci dit seulement où elle commence. DIGI, cest tous les modes numériques ensemble.', 'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
// Chiffrement des mots de passe
'gen.pwEnc': 'Chiffrement des mots de passe',
'gen.pwEncrypted': 'Mots de passe chiffrés', 'gen.pwUnlocked': '— déverrouillé', 'gen.pwLocked': '— verrouillé (à déverrouiller au lancement)',
+1
View File
@@ -34,6 +34,7 @@ const PORTABLE_KEYS = [
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
'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.matrixDigiMode', // band matrix: which digital row it opens on ('' = DIGI, the group)
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
// One imagery choice per map — world, grid squares, FT map, satellites.
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
+1 -1
View File
@@ -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.27.17';
export const APP_VERSION = '0.27.18';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.17"
appVersion = "0.27.18"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.