diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7ce222f..617121c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3652,8 +3652,9 @@ export default function App() {
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
- its right edge lines up with that column below; QTH grows to fill. */}
-
+ its right edge lines up with that column below; QTH grows to fill.
+ gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
+
Name
{ setName(e.target.value); markEdited('name'); }} />
diff --git a/frontend/src/components/NetControlPanel.tsx b/frontend/src/components/NetControlPanel.tsx
index 45fb3a9..1118f40 100644
--- a/frontend/src/components/NetControlPanel.tsx
+++ b/frontend/src/components/NetControlPanel.tsx
@@ -73,6 +73,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [wb, setWb] = useState
(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);
@@ -302,6 +316,61 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
)}
+
+ {/* 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. */}
+
+
+
+
+
+ {t('ncp.workedBefore')}
+ {wbCall && {wbCall} }
+ {wb && wb.count > 0 && (
+
+ {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})` : ''}
+
+ )}
+
+
+ {!wbCall ? (
+
{t('ncp.wbHint')}
+ ) : wbBusy ? (
+
…
+ ) : !wb || wb.count === 0 ? (
+
{t('ncp.wbNone')} {wbCall}
+ ) : (
+
+
+
+ {t('ncp.colDate')}
+ {t('ncp.colTimeOn')}
+ {t('ncp.colBand')}
+ {t('ncp.colMode')}
+ RST
+ {t('ncp.colComment')}
+
+
+
+ {((wb.entries ?? []) as any[]).map((q, i) => (
+
+ {String(q.qso_date ?? '').slice(0, 10)}
+ {String(q.time_on ?? '').slice(0, 5)}
+ {q.band}
+ {q.mode}
+ {(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}
+ {q.comment}
+
+ ))}
+
+
+ )}
+
+
+
{/* NET USERS / roster (right) */}
@@ -343,56 +412,6 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
- {/* Worked-before for the clicked station — click a row (on air or roster) to
- see if/when we contacted it before. */}
-
-
-
- {t('ncp.workedBefore')}
- {wbCall && {wbCall} }
- {wb && wb.count > 0 && (
-
- {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})` : ''}
-
- )}
-
-
- {!wbCall ? (
-
{t('ncp.wbHint')}
- ) : wbBusy ? (
-
…
- ) : !wb || wb.count === 0 ? (
-
{t('ncp.wbNone')} {wbCall}
- ) : (
-
-
-
- {t('ncp.colDate')}
- {t('ncp.colTimeOn')}
- {t('ncp.colBand')}
- {t('ncp.colMode')}
- RST
- {t('ncp.colComment')}
-
-
-
- {((wb.entries ?? []) as any[]).map((q, i) => (
-
- {String(q.qso_date ?? '').slice(0, 10)}
- {String(q.time_on ?? '').slice(0, 5)}
- {q.band}
- {q.mode}
- {(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}
- {q.comment}
-
- ))}
-
-
- )}
-
-
-
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
{editingDraft && (
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index d753277..be0a37e 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -200,7 +200,7 @@ const en: Dict = {
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
- 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
+ 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as