feat: Themes added, 4 themes available (3 dark, 1 light)

This commit is contained in:
2026-07-06 09:08:47 +02:00
parent fafa0c22ab
commit 06183bd5d4
43 changed files with 982 additions and 457 deletions
+90 -25
View File
@@ -59,6 +59,7 @@ import {
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
@@ -333,6 +334,68 @@ function TreeNodeView({
// ===== Section content panels =====
// Representative surface/card/accent swatch for each theme (picker preview —
// the theme is not applied until selected). Module-scope so their component
// identity is STABLE across SettingsModal re-renders; defining them inside the
// component would give each render a fresh function, remounting the Radix
// Select and slamming the open dropdown shut on any ambient re-render.
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
};
function ThemeSwatch({ theme }: { theme: Exclude<ThemeChoice, 'auto'> }) {
const s = THEME_SWATCH[theme];
return (
<span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0" style={{ background: s.bg }}>
<span className="m-0.5 flex flex-1 items-center justify-center rounded-[2px]" style={{ background: s.card }}>
<span className="h-2 w-2 rounded-full" style={{ background: s.accent }} />
</span>
</span>
);
}
// Auto swatch = split light/dark, since it follows the OS preference.
function ThemeOptionSwatch({ theme }: { theme: ThemeChoice }) {
if (theme === 'auto') {
return (
<span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0">
<span className="flex-1" style={{ background: '#e8dfc9' }} />
<span className="flex-1" style={{ background: '#16181d' }} />
</span>
);
}
return <ThemeSwatch theme={theme} />;
}
function ThemeSelector() {
const { theme, setTheme } = useTheme();
const { t } = useI18n();
const options: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
return (
<div className="flex items-center gap-3">
<Label className="text-sm w-40">{t('settings.theme')}</Label>
<Select value={theme} onValueChange={(v) => setTheme(v as ThemeChoice)}>
<SelectTrigger className="h-9 w-64" title={t('settings.themeHint')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
<span className="flex items-center gap-2.5">
<ThemeOptionSwatch theme={opt} />
{t(`theme.${opt}`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function SectionHeader({ title, hint }: { title: string; hint?: string }) {
return (
<header className="mb-4">
@@ -646,7 +709,7 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
</p>
</div>
{rxList.length === 0 && (
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2 max-w-xl">
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-xl">
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
</div>
)}
@@ -1474,7 +1537,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
disabled={!current}
/>
{current?.is_active && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-emerald-100 text-emerald-800 border border-emerald-300">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-success-muted text-success-muted-foreground border border-success-border">
{t('prof.active')}
</span>
)}
@@ -1577,7 +1640,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{testing ? t('lk.testing') : t('lk.test')}
</Button>
</td>
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-emerald-700' : 'text-destructive')}>
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-success' : 'text-destructive')}>
{test?.msg}
</td>
</tr>
@@ -2143,7 +2206,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className={cn(
'text-xs rounded-md p-2.5 border',
ubTest.ok
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
? 'bg-success-muted text-success-muted-foreground border-success-border'
: 'bg-destructive/10 text-destructive border-destructive/30',
)}>
{ubTest.msg}
@@ -2278,7 +2341,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className={cn(
'text-xs rounded-md p-2.5 border',
rotatorTest.ok
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
? 'bg-success-muted text-success-muted-foreground border-success-border'
: 'bg-destructive/10 text-destructive border-destructive/30',
)}>
{rotatorTest.msg}
@@ -2336,7 +2399,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode are configured on the radio; only the speed is set from here (the rig's KEY SPEED).
</p>
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
<p className="text-xs font-medium text-amber-600 -mt-1 flex items-start gap-1.5">
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
<span aria-hidden>⚠</span>
<span>
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Icom CI-V CW needs the CAT backend set to <strong>Icom</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
@@ -2536,16 +2599,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<td className="px-3 py-2 font-medium">
{s.name}
{isMaster && s.enabled && (
<span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-amber-100 text-amber-800 border border-amber-300">MASTER</span>
<span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-warning-muted text-warning-muted-foreground border border-warning-border">MASTER</span>
)}
</td>
<td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
<td className="px-3 py-2">
<span className={cn(
'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border',
state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
state === 'connecting' || state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
state === 'connecting' || state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border',
)}>
{state.toUpperCase()}
@@ -2555,7 +2618,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<td className="px-2 py-2">
<div className="flex items-center gap-0.5 justify-end">
{state === 'connected' || state === 'connecting' || state === 'reconnecting' ? (
<Button variant="ghost" size="icon" className="size-6 text-emerald-600 hover:text-emerald-700"
<Button variant="ghost" size="icon" className="size-6 text-success hover:text-success"
onClick={async () => { await DisconnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
title={t('clu.disconnect')}><Power className="size-3.5" /></Button>
) : (
@@ -2874,8 +2937,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className={cn(
'text-xs px-3 py-2 rounded-md border',
backupResult.ok
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
: 'bg-rose-50 border-rose-300 text-rose-800',
? 'bg-success-muted border-success-border text-success-muted-foreground'
: 'bg-danger-muted border-danger-border text-danger-muted-foreground',
)}>
{backupResult.msg}
</div>
@@ -3083,7 +3146,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {qrzTesting ? t('es.testing') : t('es.testConn')}
</Button>
{qrzTest && (
<span className={cn('text-xs', qrzTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
<span className={cn('text-xs', qrzTest.ok ? 'text-success' : 'text-danger')}>
{qrzTest.msg}
</span>
)}
@@ -3147,7 +3210,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {clublogTesting ? t('es.testing') : t('es.testConn')}
</Button>
{clublogTest && (
<span className={cn('text-xs', clublogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
<span className={cn('text-xs', clublogTest.ok ? 'text-success' : 'text-danger')}>
{clublogTest.msg}
</span>
)}
@@ -3206,7 +3269,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {hrdlogTesting ? t('es.testing') : t('es.testConn')}
</Button>
{hrdlogTest && (
<span className={cn('text-xs', hrdlogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
<span className={cn('text-xs', hrdlogTest.ok ? 'text-success' : 'text-danger')}>
{hrdlogTest.msg}
</span>
)}
@@ -3272,7 +3335,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {eqslTesting ? t('es.testing') : t('es.testConn')}
</Button>
{eqslTest && (
<span className={cn('text-xs', eqslTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
<span className={cn('text-xs', eqslTest.ok ? 'text-success' : 'text-danger')}>
{eqslTest.msg}
</span>
)}
@@ -3389,7 +3452,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {lotwTesting ? t('es.testing') : t('es.testConn')}
</Button>
{lotwTest && (
<span className={cn('text-xs', lotwTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
<span className={cn('text-xs', lotwTest.ok ? 'text-success' : 'text-danger')}>
{lotwTest.msg}
</span>
)}
@@ -3423,7 +3486,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
{potaResult && (
<div className={cn('text-xs rounded-md px-3 py-2 border',
potaResult.ok ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
potaResult.ok ? 'border-success-border bg-success-muted text-success-muted-foreground' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
{potaResult.msg}
</div>
)}
@@ -3514,14 +3577,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}}>
{t('db.saveSwitch')}
</Button>
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
{restartMsg && <span className="text-[11px] text-success">{restartMsg}</span>}
</div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && (
<div className="max-w-2xl mb-4">
{backendStatus.fallback ? (
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
<div className="text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
{t('db.fallback')}
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</div>
@@ -3549,7 +3612,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dbSettings.path || '—'}
{dbSettings.is_custom
? <span className="ml-2 text-[10px] text-emerald-700">{t('db.customLoc')}</span>
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
</div>
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div>
@@ -3563,7 +3626,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
{dbMsg && (
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
<div className="text-xs bg-success-muted border border-success-border text-success-muted-foreground rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
<span>{dbMsg}</span>
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>{t('db.quitNow')}</Button>
</div>
@@ -3826,6 +3889,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
<ThemeSelector />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
{t('gen.autofocusWB')}
@@ -3858,8 +3923,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<p className="text-xs text-muted-foreground">
Passwords encrypted{' '}
{secret.unlocked
? <span className="text-emerald-700 font-medium">— unlocked</span>
: <span className="text-amber-600 font-medium">— locked (unlock at launch)</span>}.
? <span className="text-success font-medium">— unlocked</span>
: <span className="text-warning font-medium">— locked (unlock at launch)</span>}.
</p>
{secret.unlocked && (
<>