feat: the header callsign is the station switcher; clock moves to the status bar
Switching station is the frequent act and editing a profile the rare one, but the callsign opened the settings — so changing station took four clicks and a scroll. It is now a dropdown of every profile: pick one and it activates. The active one is ticked and disabled, and "Manage profiles…" stays at the bottom for the rare case. The list reloads on every profile change, so a profile added or renamed in the settings appears without a restart. The UTC clock leaves the header for the status bar, next to the database: in the header it sat beside the frequency in the same weight and competed with it for the eye, while being something you consult rather than watch.
This commit is contained in:
+66
-20
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
GetUIPref, GetActiveProfile, QuitApp,
|
||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||
GetAmpStatuses, AmpOperate,
|
||||
GetFlexState, FlexAmpOperate,
|
||||
@@ -57,6 +57,9 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
@@ -2398,6 +2401,18 @@ export default function App() {
|
||||
// every profile switch; the grids take it as their React key so they remount
|
||||
// and re-read the now-correct per-profile layout.
|
||||
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
|
||||
// The station switcher in the header needs the whole list, not just the active
|
||||
// one. Reloaded on every profile change so a profile renamed or added in the
|
||||
// settings shows up without a restart.
|
||||
const [profileList, setProfileList] = useState<{ id: number; callsign: string; name: string }[]>([]);
|
||||
const loadProfileList = useCallback(() => {
|
||||
ListProfiles().then((ps: any) => {
|
||||
setProfileList((Array.isArray(ps) ? ps : []).map((p: any) => ({
|
||||
id: p.id, callsign: p.callsign ?? '', name: p.name ?? '',
|
||||
})));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { loadProfileList(); }, [loadProfileList]);
|
||||
useEffect(() => {
|
||||
GetActiveProfile().then((p: any) => {
|
||||
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
|
||||
@@ -2420,7 +2435,7 @@ export default function App() {
|
||||
// Re-scope the grid column layout BEFORE the grids remount (key change).
|
||||
setGridPrefsProfile(id ?? null);
|
||||
setActiveProfileId(typeof id === 'number' ? id : null);
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadProfileList();
|
||||
// The chat is per shared logbook — clear the previous profile's messages
|
||||
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
||||
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
|
||||
@@ -2428,7 +2443,7 @@ export default function App() {
|
||||
setChatEpoch((e) => e + 1);
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes, loadProfileList]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await reloadWk();
|
||||
@@ -4650,24 +4665,48 @@ export default function App() {
|
||||
) : <span />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
<Clock className="size-3" />
|
||||
{utcNow}<span className="text-[10px]">UTC</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{station.callsign ? (
|
||||
<button
|
||||
onClick={() => { setSettingsSection('profiles'); setShowSettings(true); }}
|
||||
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
|
||||
title="Click to switch / edit profiles"
|
||||
>
|
||||
<Antenna className="size-3" />
|
||||
{station.callsign}
|
||||
{station.my_grid && (
|
||||
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
|
||||
)}
|
||||
</button>
|
||||
// Switching station is the frequent act; editing a profile is the
|
||||
// rare one. The callsign used to open the settings, so changing
|
||||
// station took four clicks and a scroll — it now IS the switcher,
|
||||
// with the settings kept at the bottom of the list.
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
|
||||
title={t('prof.switchTitle')}
|
||||
>
|
||||
<Antenna className="size-3" />
|
||||
{station.callsign}
|
||||
{station.my_grid && (
|
||||
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
|
||||
)}
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
{profileList.map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
disabled={p.id === activeProfileId}
|
||||
onSelect={() => { if (p.id !== activeProfileId) ActivateProfile(p.id).catch((e: any) => setError(String(e?.message ?? e))); }}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{p.id === activeProfileId
|
||||
? <Check className="size-3.5 shrink-0 text-primary" />
|
||||
: <span className="size-3.5 shrink-0" />}
|
||||
<span className="font-mono font-semibold">{p.callsign || '—'}</span>
|
||||
{p.name && <span className="text-muted-foreground truncate">{p.name}</span>}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => { setSettingsSection('profiles'); setShowSettings(true); }}>
|
||||
{t('prof.manage')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
|
||||
<Settings className="size-3.5" /> Set station
|
||||
@@ -5930,6 +5969,13 @@ export default function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* UTC clock — moved out of the header, where it competed with the
|
||||
frequency for the eye. It belongs with the other passive
|
||||
indicators. */}
|
||||
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground shrink-0" title="UTC">
|
||||
<Clock className="size-3" />
|
||||
{utcNow}<span className="text-[9px]">Z</span>
|
||||
</span>
|
||||
{dbConn && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -40,7 +40,7 @@ const en: Dict = {
|
||||
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…',
|
||||
'menu.help': 'Help', 'prof.switchTitle': 'Switch station profile', 'prof.manage': 'Manage profiles…', 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…',
|
||||
'help.sendLog': 'Send log to F4BPO', 'help.sendLogBusy': 'Sending log…',
|
||||
'help.sendLogOk': 'Log sent to F4BPO — thanks, 73!', 'help.sendLogFail': 'Could not send log: {err}',
|
||||
// Duplicates modal
|
||||
@@ -456,7 +456,7 @@ const fr: Dict = {
|
||||
'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'menu.help': 'Aide', 'prof.switchTitle': 'Changer de profil de station', 'prof.manage': 'Gérer les profils…', 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'help.sendLog': 'Envoyer le journal à F4BPO', 'help.sendLogBusy': 'Envoi du journal…',
|
||||
'help.sendLogOk': 'Journal envoyé à F4BPO — merci, 73 !', 'help.sendLogFail': 'Échec de l\'envoi du journal : {err}',
|
||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||
|
||||
Reference in New Issue
Block a user