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
+83 -13
View File
@@ -54,6 +54,65 @@ func ctrlPacket(typ uint16, seq uint16, sentid, rcvdid uint32) []byte {
return b return b
} }
// passcodeSeq is Icom's fixed obfuscation table for the login username/password
// (used by RS-BA1). BEST-EFFORT public reconstruction — the values that matter
// for a given credential are sequence[char+index]; if the radio rejects auth,
// compare the "scrambled" bytes this tool prints against a real login capture to
// correct the needed entries.
var passcodeSeq = [256]byte{
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41,
0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e, 0x20, 0x77, 0x5f, 0x51, 0x3e, 0x70, 0x4d, 0x1f, 0x74, 0x38,
0x2c, 0x4b, 0x1e, 0x54, 0x30, 0x71, 0x2b, 0x2a, 0x66, 0x27, 0x2e, 0x58, 0x24, 0x21, 0x2f, 0x50,
0x1b, 0x73, 0x69, 0x36, 0x1d, 0x4f, 0x1c, 0x51, 0x2e, 0x1e, 0x45, 0x2e, 0x22, 0x50, 0x64, 0x66,
0x24, 0x36, 0x0c, 0x7d, 0x50, 0x25, 0x7c, 0x3f, 0x2d, 0x35, 0x71, 0x6a, 0x0e, 0x41, 0x2a, 0x67,
0x7c, 0x64, 0x77, 0x67, 0x6d, 0x5b, 0x3d, 0x5b, 0x2b, 0x67, 0x6c, 0x39, 0x35, 0x76, 0x3b, 0x2f,
0x2f, 0x6d, 0x59, 0x6e, 0x59, 0x77, 0x3b, 0x24, 0x74, 0x7c, 0x6b, 0x37, 0x54, 0x5c, 0x4d, 0x1f,
0x27, 0x69, 0x5b, 0x2e, 0x28, 0x35, 0x77, 0x74, 0x35, 0x1f, 0x6a, 0x2a, 0x28, 0x30, 0x25, 0x20,
}
// passcode scrambles s (username or password) via the Icom sequence table.
func passcode(s string) []byte {
out := make([]byte, len(s))
for i := 0; i < len(s); i++ {
p := int(s[i]) + i
if p > 0x7f {
p = ((p - 0x7f) % 0x33) - 1
if p < 0 {
p = 0
}
}
out[i] = passcodeSeq[p&0xff]
}
return out
}
// buildLogin builds the 0x80-byte login packet: control header + username/
// password (scrambled) at 0x40/0x50 and the app name at 0x60. The middle fields
// (payload size, request type, inner seq, token request) are a best-effort
// reconstruction and may need adjustment against a capture.
func buildLogin(seq uint16, sentid, rcvdid uint32, innerSeq, tokRequest uint16, user, pass, name string) []byte {
b := make([]byte, 0x80)
binary.LittleEndian.PutUint32(b[0:], 0x80) // len
// type (b[4:6]) = 0x00
binary.LittleEndian.PutUint16(b[6:], seq)
binary.LittleEndian.PutUint32(b[8:], sentid)
binary.LittleEndian.PutUint32(b[12:], rcvdid)
binary.LittleEndian.PutUint32(b[16:], 0x70) // payload size (len - 0x10)
binary.LittleEndian.PutUint16(b[20:], 0x00) // requesttype
binary.LittleEndian.PutUint16(b[22:], 0x01) // requestreply
binary.LittleEndian.PutUint16(b[24:], innerSeq)
binary.LittleEndian.PutUint16(b[26:], tokRequest)
// token (b[0x20:0x24]) = 0 until the rig grants one
copy(b[0x40:0x50], passcode(user))
copy(b[0x50:0x60], passcode(pass))
nm := name
if len(nm) > 16 {
nm = nm[:16]
}
copy(b[0x60:0x70], []byte(nm))
return b
}
func parseHeader(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) { func parseHeader(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) {
if len(b) < 16 { if len(b) < 16 {
return 0, 0, 0, 0, 0, false return 0, 0, 0, 0, 0, false
@@ -68,22 +127,18 @@ func parseHeader(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint3
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
fmt.Println("usage: icomnettest <rig-ip> [control-port] [run-seconds]") fmt.Println("usage: icomnettest <rig-ip> [user] [password]")
fmt.Println("example: icomnettest 192.168.1.60 50001 20") fmt.Println(" <rig-ip> only → handshake + ping probe")
fmt.Println(" <rig-ip> <user> <pass> → also attempt login")
fmt.Println("example: icomnettest 192.168.1.60 f6bgc cgb6f1")
os.Exit(2) os.Exit(2)
} }
ip := os.Args[1] ip := os.Args[1]
port := 50001 port := 50001
if len(os.Args) >= 3 { runSecs := 25
if v, err := strconv.Atoi(os.Args[2]); err == nil { user, pass := "", ""
port = v
}
}
runSecs := 20
if len(os.Args) >= 4 { if len(os.Args) >= 4 {
if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 { user, pass = os.Args[2], os.Args[3]
runSecs = v
}
} }
target := net.JoinHostPort(ip, strconv.Itoa(port)) target := net.JoinHostPort(ip, strconv.Itoa(port))
@@ -110,6 +165,14 @@ func main() {
} }
fmt.Printf("Probing Icom control stream at %s (myID=0x%08X)\n\n", target, myID) fmt.Printf("Probing Icom control stream at %s (myID=0x%08X)\n\n", target, myID)
if user != "" {
fmt.Printf("Login mode: user=%q pass=%q\n", user, pass)
fmt.Printf(" scrambled user = % X\n", passcode(user))
fmt.Printf(" scrambled pass = % X\n\n", passcode(pass))
}
var innerSeq uint16 = 0x0001
var tokRequest uint16 = 0x1234 // fixed for reproducibility (no RNG in this probe)
loginSent := false
// 1) areYouThere — ask the rig to announce itself. // 1) areYouThere — ask the rig to announce itself.
seq++ seq++
@@ -154,8 +217,15 @@ func main() {
logTx("areYouReady", ctrlPacket(typeAreYouReady, seq, myID, remoteID)) logTx("areYouReady", ctrlPacket(typeAreYouReady, seq, myID, remoteID))
readyStarted = true readyStarted = true
case typeAreYouReady: case typeAreYouReady:
if readyStarted { if readyStarted && !loginSent {
fmt.Printf(">> iAmReady — control link is up. (Login is the next milestone.)\n\n") fmt.Printf(">> iAmReady — control link is up.\n\n")
if user != "" {
seq++
lg := buildLogin(seq, myID, remoteID, innerSeq, tokRequest, user, pass, "OpsLog")
fmt.Printf(">> sending login (user=%q)\n", user)
logTx("login", lg)
loginSent = true
}
} }
case typePing: case typePing:
// Reply to the rig's ping: mirror the packet, swap sender/receiver IDs, // Reply to the rig's ping: mirror the packet, swap sender/receiver IDs,
+73 -73
View File
@@ -324,7 +324,7 @@ export default function App() {
title={`${on ? 'Unlock' : 'Lock'} ${title}`} title={`${on ? 'Unlock' : 'Lock'} ${title}`}
className={cn( className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors', 'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-amber-600 hover:text-amber-700' : 'text-muted-foreground/40 hover:text-muted-foreground', on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
)} )}
> >
<Icon className="size-3" /> <Icon className="size-3" />
@@ -2525,8 +2525,8 @@ export default function App() {
{!lookupBusy && lookupResult && (() => { {!lookupBusy && lookupResult && (() => {
const src = (lookupResult.source || '').toLowerCase(); const src = (lookupResult.source || '').toLowerCase();
const tone = src.includes('cache') ? 'amber' : src.includes('qrz') ? 'emerald' : src.includes('ham') ? 'sky' : 'slate'; const tone = src.includes('cache') ? 'amber' : src.includes('qrz') ? 'emerald' : src.includes('ham') ? 'sky' : 'slate';
const ring = { amber: 'bg-amber-500/12 text-amber-600 ring-amber-500/30', emerald: 'bg-emerald-500/12 text-emerald-600 ring-emerald-500/30', sky: 'bg-sky-500/12 text-sky-600 ring-sky-500/30', slate: 'bg-slate-500/12 text-slate-500 ring-slate-500/30' }[tone]; const ring = { amber: 'bg-warning/12 text-warning ring-warning/30', emerald: 'bg-success/12 text-success ring-success/30', sky: 'bg-info/12 text-info ring-info/30', slate: 'bg-muted-foreground/12 text-muted-foreground ring-muted-foreground/30' }[tone];
const dot = { amber: 'bg-amber-500', emerald: 'bg-emerald-500', sky: 'bg-sky-500', slate: 'bg-slate-400' }[tone]; const dot = { amber: 'bg-warning', emerald: 'bg-success', sky: 'bg-info', slate: 'bg-muted-foreground' }[tone];
return ( return (
<span className={cn('inline-flex items-center gap-1 rounded-full px-1.5 py-[1px] text-[9px] font-semibold uppercase tracking-wide ring-1 ring-inset', ring)}> <span className={cn('inline-flex items-center gap-1 rounded-full px-1.5 py-[1px] text-[9px] font-semibold uppercase tracking-wide ring-1 ring-inset', ring)}>
<span className={cn('size-1.5 rounded-full', dot)} />{lookupResult.source} <span className={cn('size-1.5 rounded-full', dot)} />{lookupResult.source}
@@ -2534,7 +2534,7 @@ export default function App() {
); );
})()} })()}
{!lookupBusy && !lookupResult && lookupError && ( {!lookupBusy && !lookupResult && lookupError && (
<span className="inline-flex items-center rounded-full bg-rose-500/12 px-1.5 py-[1px] text-[9px] font-semibold text-rose-600 ring-1 ring-inset ring-rose-500/30">{lookupError}</span> <span className="inline-flex items-center rounded-full bg-danger/12 px-1.5 py-[1px] text-[9px] font-semibold text-danger ring-1 ring-inset ring-danger/30">{lookupError}</span>
)} )}
{callsign.trim() && ( {callsign.trim() && (
<button <button
@@ -2552,7 +2552,7 @@ export default function App() {
</Label> </Label>
<div className="relative"> <div className="relative">
{contest.active && contestDupe && ( {contest.active && contestDupe && (
<span className="absolute -top-2 right-1 z-20 rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-black tracking-widest text-white shadow animate-pulse pointer-events-none"> <span className="absolute -top-2 right-1 z-20 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-black tracking-widest text-destructive-foreground shadow animate-pulse pointer-events-none">
DUPE DUPE
</span> </span>
)} )}
@@ -2562,16 +2562,16 @@ export default function App() {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={resetRecordingClock} onClick={resetRecordingClock}
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange" title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap cursor-pointer rounded px-1 hover:bg-red-50" className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
> >
<span className="size-2 rounded-full bg-red-600 animate-pulse" /> <span className="size-2 rounded-full bg-destructive animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')} {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</button> </button>
)} )}
<Input <Input
ref={callsignRef} ref={callsignRef}
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card', className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
contest.active && contestDupe && 'ring-2 ring-red-500 !bg-red-50 focus:!bg-red-50 text-red-700')} contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')}
value={callsign} value={callsign}
onChange={(e) => onCallsignInput(e.target.value)} onChange={(e) => onCallsignInput(e.target.value)}
onBlur={() => { onBlur={() => {
@@ -2608,7 +2608,7 @@ export default function App() {
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time. // a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
const dateBlock = locks.start ? ( const dateBlock = locks.start ? (
<div className="flex flex-col w-40"> <div className="flex flex-col w-40">
<Label className="mb-1 h-3.5 text-emerald-700">Date</Label> <Label className="mb-1 h-3.5 text-success">Date</Label>
<Input <Input
type="date" type="date"
value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''} value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''}
@@ -2626,7 +2626,7 @@ export default function App() {
) : null; ) : null;
const startBlock = ( const startBlock = (
<div className="flex flex-col w-28"> <div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
<Input <Input
readOnly={!locks.start} readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1} tabIndex={locks.start ? 0 : -1}
@@ -2645,7 +2645,7 @@ export default function App() {
); );
const endBlock = ( const endBlock = (
<div className="flex flex-col w-28"> <div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-rose-700">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
<Input <Input
readOnly={!locks.end} readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1} tabIndex={locks.end ? 0 : -1}
@@ -2682,7 +2682,7 @@ export default function App() {
<div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label> <div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label>
<Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" /> <Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
</div> </div>
<div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-amber-600 font-bold">{t('field.rcv')}</Label> <div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-warning font-bold">{t('field.rcv')}</Label>
<Input value={rcv} placeholder="exch" className="font-mono" <Input value={rcv} placeholder="exch" className="font-mono"
onChange={(e) => setRcv(e.target.value.toUpperCase())} /> onChange={(e) => setRcv(e.target.value.toUpperCase())} />
</div> </div>
@@ -2764,7 +2764,7 @@ export default function App() {
); );
const rxFreqBlock = ( const rxFreqBlock = (
<div className="flex flex-col w-32"> <div className="flex flex-col w-32">
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>{t('field.rxFreq')}</Label> <Label className={cn('mb-1 h-3.5', catState.split && 'text-danger')}>{t('field.rxFreq')}</Label>
<Input <Input
tabIndex={-1} tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')} value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
@@ -2772,7 +2772,7 @@ export default function App() {
onFocus={() => setFreqFocused(true)} onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)} onBlur={() => setFreqFocused(false)}
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }} onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')} className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
/> />
</div> </div>
); );
@@ -2924,7 +2924,7 @@ export default function App() {
type="button" type="button"
onClick={() => setClusterLockBand((v) => !v)} onClick={() => setClusterLockBand((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border', className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')} clusterLockBand ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
title="Lock to the entry strip's current band" title="Lock to the entry strip's current band"
> >
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band} {clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
@@ -2957,7 +2957,7 @@ export default function App() {
type="button" type="button"
onClick={() => setClusterLockMode((v) => !v)} onClick={() => setClusterLockMode((v) => !v)}
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]', className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')} clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
title="Only show spots whose mode matches the entry strip" title="Only show spots whose mode matches the entry strip"
> >
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode}) {clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
@@ -2968,10 +2968,10 @@ export default function App() {
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div> <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{([ {([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' }, { k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' }, { k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' }, { k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' }, { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.) // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
]).map((s) => { ]).map((s) => {
const on = clusterStatusFilter.has(s.k); const on = clusterStatusFilter.has(s.k);
@@ -2991,9 +2991,9 @@ export default function App() {
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div> <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{([ {([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' }, { k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' }, { k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' }, { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
]).map((s) => { ]).map((s) => {
const on = clusterModeFilter.has(s.k); const on = clusterModeFilter.has(s.k);
return ( return (
@@ -3121,7 +3121,7 @@ export default function App() {
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span> <span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<span className="text-[9px] text-muted-foreground">MHz</span> <span className="text-[9px] text-muted-foreground">MHz</span>
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge> <Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[9px] py-0" variant="outline">{mode}</Badge> <Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
</div> </div>
<span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span> <span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
<div className="flex-1" /> <div className="flex-1" />
@@ -3154,10 +3154,10 @@ export default function App() {
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button> <button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in"> <div className="flex items-center gap-1.5 rounded-md border border-success-border bg-success-muted text-success-muted-foreground px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" /> <Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span> <span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button> <button className="shrink-0 text-success hover:text-success" onClick={dismissToast}><X className="size-3" /></button>
</div> </div>
)} )}
</div> </div>
@@ -3166,14 +3166,14 @@ export default function App() {
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span> <span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
{catState.split && rxFreqMhz && ( {catState.split && rxFreqMhz && (
<span className="text-[10px] text-muted-foreground mt-0.5"> <span className="text-[10px] text-muted-foreground mt-0.5">
<span className="text-rose-600 font-semibold mr-1">RX</span> <span className="text-danger font-semibold mr-1">RX</span>
{fmtFreqDots(rxFreqMhz)} {fmtFreqDots(rxFreqMhz)}
</span> </span>
)} )}
</div> </div>
<span className="text-[10px] text-muted-foreground uppercase">MHz</span> <span className="text-[10px] text-muted-foreground uppercase">MHz</span>
{catState.split && ( {catState.split && (
<Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge> <Badge className="bg-warning-muted text-warning-muted-foreground border-warning-border font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
)} )}
{/* Band & mode removed here — shown in the QSO entry strip below. */} {/* Band & mode removed here — shown in the QSO entry strip below. */}
{catState.enabled && catState.backend === 'omnirig' && ( {catState.enabled && catState.backend === 'omnirig' && (
@@ -3197,7 +3197,7 @@ export default function App() {
const disabled = !p; const disabled = !p;
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err))); const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
return ( return (
<div className="inline-flex items-center rounded-full border border-sky-300 bg-sky-100 overflow-hidden text-[11px] font-mono font-semibold"> <div className="inline-flex items-center rounded-full border border-info-border bg-info-muted overflow-hidden text-[11px] font-mono font-semibold">
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
@@ -3209,7 +3209,7 @@ export default function App() {
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors', 'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
disabled disabled
? 'text-muted-foreground/60 cursor-not-allowed' ? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer', : 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
)} )}
> >
<Compass className="size-3" /> <Compass className="size-3" />
@@ -3221,10 +3221,10 @@ export default function App() {
onClick={() => p && goto(p.bearingLong)} onClick={() => p && goto(p.bearingLong)}
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''} title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
className={cn( className={cn(
'px-1.5 py-0.5 border-l border-sky-300 text-[10px] transition-colors', 'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
disabled disabled
? 'text-muted-foreground/60 cursor-not-allowed' ? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer', : 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
)} )}
> >
LP {p ? `${Math.round(p.bearingLong)}°` : '—'} LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
@@ -3233,7 +3233,7 @@ export default function App() {
type="button" type="button"
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))} onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
title="Stop rotation" title="Stop rotation"
className="px-1.5 py-0.5 border-l border-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors" className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
> >
<Square className="size-2.5 fill-current" /> <Square className="size-2.5 fill-current" />
</button> </button>
@@ -3243,16 +3243,16 @@ export default function App() {
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */} {/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
{ubStatus.enabled && ( {ubStatus.enabled && (
<div className="inline-flex items-center rounded-full border border-emerald-300 bg-emerald-50 overflow-hidden text-[10px] font-semibold ml-1" <div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}> title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings"> <button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} /> <span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
</button> </button>
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => ( {([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t} <button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }} onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
className={cn('px-1.5 py-0.5 transition-colors', className={cn('px-1.5 py-0.5 transition-colors',
ubStatus.direction === o.d ? 'bg-emerald-600 text-white' : 'text-emerald-800 hover:bg-emerald-100', ubStatus.direction === o.d ? 'bg-success text-success-foreground' : 'text-success-muted-foreground hover:bg-success-muted',
!ubStatus.connected && 'opacity-40 cursor-default')}> !ubStatus.connected && 'opacity-40 cursor-default')}>
{o.l} {o.l}
</button> </button>
@@ -3268,13 +3268,13 @@ export default function App() {
title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'} title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'}
className={cn( className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors', 'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
dvkStat.playing ? 'border-rose-300 bg-rose-100 text-rose-700' dvkStat.playing ? 'border-danger-border bg-danger-muted text-danger-muted-foreground'
: dvkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100' : dvkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted', : 'border-border text-muted-foreground hover:bg-muted',
)} )}
> >
<Mic className="size-4" /> <Mic className="size-4" />
{dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-rose-500 animate-pulse" />} {dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-danger animate-pulse" />}
</button> </button>
<button <button
type="button" type="button"
@@ -3282,13 +3282,13 @@ export default function App() {
title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'} title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'}
className={cn( className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors', 'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
wkStatus.busy ? 'border-amber-300 bg-amber-100 text-amber-800' wkStatus.busy ? 'border-warning-border bg-warning-muted text-warning-muted-foreground'
: wkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100' : wkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted', : 'border-border text-muted-foreground hover:bg-muted',
)} )}
> >
<Zap className="size-4" /> <Zap className="size-4" />
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />} {wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
</button> </button>
<button <button
type="button" type="button"
@@ -3300,13 +3300,13 @@ export default function App() {
} }
className={cn( className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors', 'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
cwOn && cwStatus.active ? 'border-emerald-400 bg-emerald-100 text-emerald-800' cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
: cwEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100' : cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted', : 'border-border text-muted-foreground hover:bg-muted',
)} )}
> >
<Ear className="size-4" /> <Ear className="size-4" />
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500 animate-pulse" />} {cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
</button> </button>
<button <button
type="button" type="button"
@@ -3314,7 +3314,7 @@ export default function App() {
title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'} title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
className={cn( className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors', 'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showRotor ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100' showRotor ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted', : 'border-border text-muted-foreground hover:bg-muted',
)} )}
> >
@@ -3327,12 +3327,12 @@ export default function App() {
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'} title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
className={cn( className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors', 'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showAntGenius ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100' showAntGenius ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted', : 'border-border text-muted-foreground hover:bg-muted',
)} )}
> >
<Antenna className="size-4" /> <Antenna className="size-4" />
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500" />} {showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button> </button>
)} )}
{chatAvailable && ( {chatAvailable && (
@@ -3341,12 +3341,12 @@ export default function App() {
onClick={() => setChatOpen((o) => !o)} onClick={() => setChatOpen((o) => !o)}
title={`Multi-op chat${chatUnread > 0 ? `${chatUnread} new` : ''}`} title={`Multi-op chat${chatUnread > 0 ? `${chatUnread} new` : ''}`}
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors', className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
chatShown ? 'border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100' chatShown ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
: 'border-border text-muted-foreground hover:bg-muted')} : 'border-border text-muted-foreground hover:bg-muted')}
> >
<MessageSquare className="size-4" /> <MessageSquare className="size-4" />
{chatUnread > 0 && ( {chatUnread > 0 && (
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-rose-500 text-white text-[9px] font-bold leading-[14px] text-center"> <span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
{chatUnread > 9 ? '9+' : chatUnread} {chatUnread > 9 ? '9+' : chatUnread}
</span> </span>
)} )}
@@ -3369,7 +3369,7 @@ export default function App() {
<Antenna className="size-3" /> <Antenna className="size-3" />
{station.callsign} {station.callsign}
{station.my_grid && ( {station.my_grid && (
<span className="bg-white/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span> <span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
)} )}
</button> </button>
) : ( ) : (
@@ -3468,7 +3468,7 @@ export default function App() {
Update available: v{updateInfo.latest} — download Update available: v{updateInfo.latest} — download
</button> </button>
) : ( ) : (
<p className="mt-1 text-[11px] text-emerald-600">You're up to date</p> <p className="mt-1 text-[11px] text-success">You're up to date</p>
)} )}
<p className="mt-3 text-sm"> <p className="mt-3 text-sm">
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span> Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
@@ -3746,12 +3746,12 @@ export default function App() {
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */} {/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && ( {cwOn && (
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs"> <div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} /> <Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
{/* Input-level meter — if this stays flat with a strong signal, the RX {/* Input-level meter — if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */} audio device is wrong/silent rather than a decode problem. */}
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}> <div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
<div className="h-full bg-emerald-500 transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} /> <div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
</div> </div>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums"> <span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'} {cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
@@ -3763,7 +3763,7 @@ export default function App() {
onChange={(e) => setCwPitch(e.target.value)} onChange={(e) => setCwPitch(e.target.value)}
placeholder="auto" placeholder="auto"
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search." title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
className="shrink-0 w-14 h-5 rounded border border-emerald-300/70 bg-white/60 px-1 text-[10px] font-mono text-center outline-none" className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
/> />
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest {/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
text (see cwScrollRef effect) so the latest stays in view. */} text (see cwScrollRef effect) so the latest stays in view. */}
@@ -3776,7 +3776,7 @@ export default function App() {
<button <button
key={i} key={i}
type="button" type="button"
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-emerald-200/70" className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
title="Use as callsign" title="Use as callsign"
onClick={() => onCallsignInput(tok, { force: true })} onClick={() => onCallsignInput(tok, { force: true })}
> >
@@ -3813,9 +3813,9 @@ export default function App() {
<TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger> <TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger>
<TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger> <TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger>
{contestTabEnabled && ( {contestTabEnabled && (
<TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-amber-600 data-[state=active]:text-amber-600')}> <TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-warning data-[state=active]:text-warning')}>
{t('tab.contest')} {t('tab.contest')}
{contest.active && <span className="inline-block size-1.5 rounded-full bg-amber-500 animate-pulse" />} {contest.active && <span className="inline-block size-1.5 rounded-full bg-warning animate-pulse" />}
<span <span
role="button" role="button"
aria-label="Close Contest" aria-label="Close Contest"
@@ -3879,20 +3879,20 @@ export default function App() {
<div className={cn( <div className={cn(
'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5', 'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5',
importResult.errors && importResult.errors.length > 0 importResult.errors && importResult.errors.length > 0
? 'bg-amber-50 border-amber-300 text-amber-800' ? 'bg-warning-muted border-warning-border text-warning-muted-foreground'
: 'bg-emerald-50 border-emerald-300 text-emerald-800', : 'bg-success-muted border-success-border text-success-muted-foreground',
)}> )}>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong> <strong>Import complete.</strong>
<Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
{importResult.updated > 0 && ( {importResult.updated > 0 && (
<Badge variant="outline" className="bg-white/60 font-mono text-violet-700 border-violet-300">{importResult.updated} updated</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge>
)} )}
{importResult.duplicates > 0 && ( {importResult.duplicates > 0 && (
<Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge>
)} )}
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge>
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge> <Badge variant="outline" className="bg-card/60 font-mono">{importResult.total} total</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && ( {importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}> <button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
{importDupsOpen ? 'Hide' : 'Show'} duplicates {importDupsOpen ? 'Hide' : 'Show'} duplicates
@@ -3968,7 +3968,7 @@ export default function App() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && ( {qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-amber-700">Limit reached raise Max to see more.</span> <span className="text-warning">Limit reached raise Max to see more.</span>
)} )}
<Label className="text-[11px] text-muted-foreground">Max</Label> <Label className="text-[11px] text-muted-foreground">Max</Label>
<Input <Input
@@ -4026,14 +4026,14 @@ export default function App() {
key={s.server_id} key={s.server_id}
className={cn( className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border', 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
s.state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' : s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' : s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border', 'bg-muted text-muted-foreground border-border',
)} )}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`} title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
> >
{isMaster && <span className="text-amber-600" title="Master (commands go here)"></span>} {isMaster && <span className="text-warning" title="Master (commands go here)"></span>}
{s.name} {s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span> <span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
</span> </span>
@@ -4254,7 +4254,7 @@ export default function App() {
: 'border-border hover:bg-muted cursor-pointer', : 'border-border hover:bg-muted cursor-pointer',
)} )}
> >
<span className={cn('size-2 rounded-full', on ? 'bg-emerald-500' : 'bg-muted-foreground/40')} /> <span className={cn('size-2 rounded-full', on ? 'bg-success' : 'bg-muted-foreground/40')} />
{label} {label}
</button> </button>
); );
@@ -4286,7 +4286,7 @@ export default function App() {
title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`} title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]" className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
> >
<Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-emerald-600' : 'text-muted-foreground')} /> <Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-success' : 'text-muted-foreground')} />
<span className="font-mono truncate">{dbConn.label}</span> <span className="font-mono truncate">{dbConn.label}</span>
</button> </button>
)} )}
+1 -1
View File
@@ -109,7 +109,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
{!def && ''} {!def && ''}
</span> </span>
)} )}
{!def && <span className="block text-[10px] text-amber-600 leading-tight">{t('adx.nonStandard')}</span>} {!def && <span className="block text-[10px] text-warning leading-tight">{t('adx.nonStandard')}</span>}
</div> </div>
<Input <Input
className="flex-1 h-8 text-xs font-mono" className="flex-1 h-8 text-xs font-mono"
+3 -3
View File
@@ -124,7 +124,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }} <button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5', className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}> draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} /> <span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
<span className="truncate flex-1">{r.name}</span> <span className="truncate flex-1">{r.name}</span>
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />} {r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />} {r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
@@ -240,9 +240,9 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
{/* Editor actions */} {/* Editor actions */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60"> <div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>} {err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
<div className="flex-1" /> <div className="flex-1" />
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button> <Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button> <Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
</div> </div>
</Tabs> </Tabs>
+4 -4
View File
@@ -54,12 +54,12 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
return ( return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden"> <div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0"> <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} /> <Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span> <span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
<span className="flex-1" /> <span className="flex-1" />
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider"> <span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} /> <span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? t('agp.online') : t('agp.offline')}</span> <span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
</span> </span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}> <button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
<X className="size-3.5" /> <X className="size-3.5" />
@@ -70,7 +70,7 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
{!status.connected ? ( {!status.connected ? (
<div className="text-center py-6 text-xs space-y-2"> <div className="text-center py-6 text-xs space-y-2">
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div> <div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>} {status.last_error && <div className="text-danger font-mono text-[10px] break-words px-2">{status.last_error}</div>}
</div> </div>
) : list.length === 0 ? ( ) : list.length === 0 ? (
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div> <div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
+2 -2
View File
@@ -264,7 +264,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<button key={i} onClick={() => setSel(i)} <button key={i} onClick={() => setSel(i)}
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30', className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}> i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} /> <span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
<span className="font-mono font-semibold shrink-0">{d.code}</span> <span className="font-mono font-semibold shrink-0">{d.code}</span>
<span className="text-muted-foreground truncate">{d.name}</span> <span className="text-muted-foreground truncate">{d.name}</span>
</button> </button>
@@ -567,7 +567,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>} {busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
{!busy && large && q.trim().length < 2 && ( {!busy && large && q.trim().length < 2 && (
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800"> <div className="m-2 rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground">
{t('awed.tooManyItems', { total: total.toLocaleString() })} {t('awed.tooManyItems', { total: total.toLocaleString() })}
</div> </div>
)} )}
+2 -2
View File
@@ -50,9 +50,9 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
<label className="text-xs font-semibold text-muted-foreground">{label}</label> <label className="text-xs font-semibold text-muted-foreground">{label}</label>
<div className="relative" ref={boxRef}> <div className="relative" ref={boxRef}>
{value ? ( {value ? (
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs"> <div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-success-border bg-success-muted text-success-muted-foreground text-xs">
<span className="font-mono font-semibold">{value}</span> <span className="font-mono font-semibold">{value}</span>
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button> <button className="ml-auto hover:text-success" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
</div> </div>
) : ( ) : (
<input <input
+7 -7
View File
@@ -218,10 +218,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
{/* Selected ref chip */} {/* Selected ref chip */}
{selectedRef ? ( {selectedRef ? (
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0"> <div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span> <span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span> <span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}> <button className="ml-auto shrink-0 hover:text-success" onClick={() => setSelectedRef(null)}>
<X className="size-3" /> <X className="size-3" />
</button> </button>
</div> </div>
@@ -284,15 +284,15 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)} onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${ className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
autoAlreadyAdded autoAlreadyAdded
? 'border-emerald-300 bg-emerald-50 text-emerald-800 cursor-default' ? 'border-success-border bg-success-muted text-success-muted-foreground cursor-default'
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100' : 'border-success-border bg-success-muted/60 text-success-muted-foreground hover:bg-success-muted'
}`} }`}
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })} title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
> >
{autoAlreadyAdded ? '✓ ' : '+ '} {autoAlreadyAdded ? '✓ ' : '+ '}
<span className="font-mono font-semibold">{autoMatch.code}</span> <span className="font-mono font-semibold">{autoMatch.code}</span>
<span className="text-emerald-700"> {t('awrs.fromField', { field: selField })}</span> <span className="text-success-muted-foreground"> {t('awrs.fromField', { field: selField })}</span>
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">{t('awrs.autoClickToAdd')}</span>} {!autoAlreadyAdded && <span className="block text-[10px] text-success-muted-foreground/80">{t('awrs.autoClickToAdd')}</span>}
</button> </button>
)} )}
<input <input
+20 -20
View File
@@ -36,9 +36,9 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
return 'none'; return 'none';
} }
const CELL_STYLE: Record<CellStatus, string> = { const CELL_STYLE: Record<CellStatus, string> = {
validated: 'bg-emerald-500 text-white', validated: 'bg-success text-success-foreground',
confirmed: 'bg-amber-400 text-amber-950', confirmed: 'bg-warning text-warning-foreground',
worked: 'bg-stone-400 text-white', worked: 'bg-muted-foreground text-background',
none: '', none: '',
}; };
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' }; const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
@@ -53,8 +53,8 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
if (total <= 0) return null; if (total <= 0) return null;
return ( return (
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex"> <div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} /> <div className="bg-success" style={{ width: `${pct(confirmed, total)}%` }} />
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} /> <div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
</div> </div>
); );
} }
@@ -255,7 +255,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="font-semibold text-sm">{a.code}</span> <span className="font-semibold text-sm">{a.code}</span>
{r ? ( {r ? (
<span className="text-[11px] font-mono text-muted-foreground"> <span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-600">{r.confirmed}</span> <span className="text-success">{r.confirmed}</span>
/<span className="text-foreground">{r.worked}</span> /<span className="text-foreground">{r.worked}</span>
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>} {r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
</span> </span>
@@ -286,8 +286,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div> </div>
<div className="mt-1 flex items-center gap-4 text-sm"> <div className="mt-1 flex items-center gap-4 text-sm">
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span> <span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span> <span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span> <span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
{current.total > 0 && ( {current.total > 0 && (
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span> <span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
)} )}
@@ -303,7 +303,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{(current.bands ?? []).map((b) => ( {(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm"> <div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
<span className="font-mono font-semibold">{b.band}</span>{' '} <span className="font-mono font-semibold">{b.band}</span>{' '}
<span className="text-emerald-600 font-mono">{b.confirmed}</span> <span className="text-success font-mono">{b.confirmed}</span>
<span className="text-muted-foreground font-mono">/{b.worked}</span> <span className="text-muted-foreground font-mono">/{b.worked}</span>
</div> </div>
))} ))}
@@ -328,7 +328,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span> <span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
<button <button
onClick={() => setShowMissing(true)} onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1" className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
title={t('awp.missingRefsTitle')} title={t('awp.missingRefsTitle')}
> >
<AlertTriangle className="size-3" /> {t('awp.missingRefs')} <AlertTriangle className="size-3" /> {t('awp.missingRefs')}
@@ -336,9 +336,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex-1" /> <div className="flex-1" />
{/* Legend */} {/* Legend */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground"> <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span> <span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-muted-foreground" />W</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span> <span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-warning" />C</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span> <span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-success" />V</span>
</div> </div>
<div className="flex items-center rounded-md border border-border overflow-hidden"> <div className="flex items-center rounded-md border border-border overflow-hidden">
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button> <button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
@@ -370,7 +370,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap', <td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td> isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return ( {statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td> <td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}</td>
); })} ); })}
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td> <td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td> <td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
@@ -439,13 +439,13 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td> <td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
<td className="py-1 pr-2"> <td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span> {!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
: r.validated ? <span className="text-emerald-600">{t('awp.validated')}</span> : r.validated ? <span className="text-success">{t('awp.validated')}</span>
: r.confirmed ? <span className="text-amber-600">{t('awp.confirmed')}</span> : r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
: <span className="text-stone-500">{t('awp.worked')}</span>} : <span className="text-muted-foreground">{t('awp.worked')}</span>}
</td> </td>
<td className="py-1 font-mono text-muted-foreground"> <td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => ( {r.bands.map((b) => (
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span> <span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-success font-semibold' : r.confirmed_bands.includes(b) && 'text-warning font-semibold')}>{b}</span>
))} ))}
</td> </td>
</tr> </tr>
@@ -558,7 +558,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}> <div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}> <div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b"> <div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" /> <AlertTriangle className="size-4 text-warning" />
<span className="font-semibold text-sm">{code} {t('awp.contactsMissingRef')}</span> <span className="font-semibold text-sm">{code} {t('awp.contactsMissingRef')}</span>
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>} {name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
<div className="flex-1" /> <div className="flex-1" />
@@ -589,7 +589,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}> <Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })} {busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
</Button> </Button>
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>} {msg && <span className="text-[11px] text-success">{msg}</span>}
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
+27 -27
View File
@@ -61,17 +61,17 @@ const BAND_RANGES: Record<string, [number, number]> = {
}; };
const SEGMENT_COLORS: Record<string, [number, number, string][]> = { const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']], '160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']], '80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
'60m': [[5350, 5450, 'fill-amber-50']], '60m': [[5350, 5450, 'fill-warning-muted']],
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']], '40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']], '30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']], '20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']], '17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']], '15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']], '12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']], '10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']], '6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
}; };
// Small coloured dot + label used in the band-map legend strip. // Small coloured dot + label used in the band-map legend strip.
@@ -103,22 +103,22 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
// dot = small marker on the freq scale // dot = small marker on the freq scale
switch (s) { switch (s) {
case 'new': return { case 'new': return {
pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100', pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
bar: 'bg-rose-500', bar: 'bg-danger',
line: 'stroke-rose-400', line: 'stroke-danger',
dot: 'fill-rose-500', dot: 'fill-danger',
}; };
case 'new-band': return { case 'new-band': return {
pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100', pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
bar: 'bg-amber-500', bar: 'bg-warning',
line: 'stroke-amber-400', line: 'stroke-warning',
dot: 'fill-amber-500', dot: 'fill-warning',
}; };
case 'new-slot': return { case 'new-slot': return {
pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100', pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
bar: 'bg-yellow-500', bar: 'bg-caution',
line: 'stroke-yellow-500', line: 'stroke-caution',
dot: 'fill-yellow-500', dot: 'fill-caution',
}; };
case 'worked': return { case 'worked': return {
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50', pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
@@ -520,14 +520,14 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
</div> </div>
{/* Colour legend — what each pill colour means. */} {/* Colour legend — what each pill colour means. */}
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border"> <div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
<LegendDot cls="bg-rose-400" label={t('bmp.legendNewDxcc')} /> <LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-amber-400" label={t('bmp.legendNewBand')} /> <LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-yellow-300" label={t('bmp.legendNewSlot')} /> <LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} /> <LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
</div> </div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0"> <div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
{t('bmp.footerHint')} {t('bmp.footerHint')}
{hidden > 0 && <span className="text-amber-600"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>} {hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
</div> </div>
</div> </div>
); );
+26 -21
View File
@@ -51,22 +51,27 @@ function classMatchesMode(cls: string, mode: string): boolean {
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u); return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
} }
// Dedicated matrix palette (--mx-* tokens, per theme in style.css): a 3-level
// ramp per hue so confirmed / worked / not-worked stay distinguishable on both
// light and dark surfaces (the generic status -muted fills were too dark on the
// dark themes to tell "worked" from "not worked" apart). Light-warm reproduces
// the original emerald/indigo/stone colours exactly.
const STATUS_CLASSES: Record<string, string> = { const STATUS_CLASSES: Record<string, string> = {
call_c: 'bg-emerald-700 hover:bg-emerald-600', call_c: 'bg-mx-call-conf',
call_w: 'bg-emerald-300 hover:bg-emerald-200', call_w: 'bg-mx-call-work',
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700', dxcc_c: 'bg-mx-dx-conf',
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200', dxcc_w: 'bg-mx-dx-work',
}; };
// Legend entries, in the same colour order as the cells. swatch = the // Legend entries, in the same colour order as the cells. swatch = the
// background class (or a special ring marker for the current-entry cell). // background class (or a special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [ const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
{ swatch: 'bg-emerald-700', label: 'Call confirmed' }, { swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
{ swatch: 'bg-emerald-300', label: 'Call worked' }, { swatch: 'bg-mx-call-work', label: 'Call worked' },
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' }, { swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
{ swatch: 'bg-indigo-300', label: 'Entity worked' }, { swatch: 'bg-mx-dx-work', label: 'Entity worked' },
{ swatch: 'bg-stone-200', label: 'Not worked' }, { swatch: 'bg-mx-none', label: 'Not worked' },
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' }, { swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
]; ];
function cellTitle(band: string, cls: string, status: string, current: boolean): string { function cellTitle(band: string, cls: string, status: string, current: boolean): string {
@@ -128,18 +133,18 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<section <section
className={cn( className={cn(
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0', 'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg', newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
)} )}
> >
<div className="flex items-center gap-2 min-w-[220px]"> <div className="flex items-center gap-2 min-w-[220px]">
{newOne ? ( {newOne ? (
<> <>
<Badge className="bg-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]"> <Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
<Star className="size-3 fill-current" /> <Star className="size-3 fill-current" />
NEW ONE NEW ONE
</Badge> </Badge>
<span className="text-xs text-amber-900"> <span className="text-xs text-warning-muted-foreground">
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong> <strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity {' '}· never worked this entity
</span> </span>
</> </>
@@ -161,10 +166,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</span> </span>
{(newBand || newMode || newBandMode || newSlot) && ( {(newBand || newMode || newBandMode || newSlot) && (
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>} {newBandMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>}
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>} {newBand && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band</Badge>}
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>} {newMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Mode</Badge>}
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>} {newSlot && <Badge className="bg-info text-info-foreground px-1.5 py-0 text-[10px]">New Slot</Badge>}
</div> </div>
)} )}
</> </>
@@ -220,8 +225,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
title={cellTitle(b.tag, cls, st, isCurrent)} title={cellTitle(b.tag, cls, st, isCurrent)}
className={cn( className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0', 'w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300', st ? STATUS_CLASSES[st] : 'bg-mx-none',
isCurrent && 'ring-2 ring-amber-500 ring-inset', isCurrent && 'ring-2 ring-warning ring-inset',
)} )}
/> />
); );
@@ -240,7 +245,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
className={cn( className={cn(
'inline-block size-3 rounded shrink-0', 'inline-block size-3 rounded shrink-0',
l.swatch, l.swatch,
l.ring && 'ring-2 ring-amber-500 ring-inset', l.ring && 'ring-2 ring-warning ring-inset',
)} )}
/> />
{l.label} {l.label}
+1 -1
View File
@@ -160,7 +160,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '} {t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })} <span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
</div> </div>
{error && <div className="text-xs text-rose-700">{error}</div>} {error && <div className="text-xs text-danger">{error}</div>}
</div> </div>
<DialogFooter> <DialogFooter>
+10 -10
View File
@@ -76,32 +76,32 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
<table className="w-full text-[11px] border-collapse"> <table className="w-full text-[11px] border-collapse">
<thead> <thead>
<tr> <tr>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th> <th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th> <th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th> <th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th> <th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th> <th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th> <th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.map((e) => ( {entries.map((e) => (
<tr key={e.id} className="hover:bg-stone-50"> <tr key={e.id} className="hover:bg-card">
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td> <td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap"> <td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span> <span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
</td> </td>
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap"> <td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-emerald-100 text-emerald-700">{e.mode}</span> <span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-success-muted text-success-muted-foreground">{e.mode}</span>
</td> </td>
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td> <td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td> <td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground"> <td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
{e.lotw_rcvd === 'Y' && ( {e.lotw_rcvd === 'Y' && (
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title={t('chp.lotwRcvd')}>L</span> <span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
)} )}
{e.qsl_rcvd === 'Y' && ( {e.qsl_rcvd === 'Y' && (
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title={t('chp.bureauRcvd')}>B</span> <span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
)} )}
</td> </td>
</tr> </tr>
+2 -2
View File
@@ -35,7 +35,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
return ( return (
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden"> <div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0"> <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
<MessageSquare className="size-4 text-sky-600" /> <MessageSquare className="size-4 text-info" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span> <span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
<span className="flex-1" /> <span className="flex-1" />
{/* Online count — hover to see who's connected. */} {/* Online count — hover to see who's connected. */}
@@ -66,7 +66,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
const mine = m.operator.toUpperCase() === me; const mine = m.operator.toUpperCase() === me;
return ( return (
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}> <div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}> <div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-info-muted text-info-muted-foreground' : 'bg-muted')}>
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>} {!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
<span className="whitespace-pre-wrap break-words">{m.message}</span> <span className="whitespace-pre-wrap break-words">{m.message}</span>
</div> </div>
+3 -22
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react'; import { Columns3, FilterX } from 'lucide-react';
import { import {
@@ -18,27 +19,7 @@ type TFn = (key: string, vars?: Record<string, string | number>) => string;
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({ const hamlogTheme = hamlogGridTheme;
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
export type ClusterSpot = { export type ClusterSpot = {
source_id: number; source_id: number;
+4 -4
View File
@@ -85,9 +85,9 @@ export function ContestPanel({ session, onChange }: {
{/* Setup card. */} {/* Setup card. */}
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"> <div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30"> <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
<Trophy className="size-4 text-amber-500" /> <Trophy className="size-4 text-warning" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span> <span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
{session.active && <span className="ml-auto rounded bg-amber-500 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-white">{t('ctp.live')}</span>} {session.active && <span className="ml-auto rounded bg-warning px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-warning-foreground">{t('ctp.live')}</span>}
</div> </div>
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -150,13 +150,13 @@ export function ContestPanel({ session, onChange }: {
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
{session.active ? ( {session.active ? (
<button type="button" onClick={() => onChange({ active: false })} <button type="button" onClick={() => onChange({ active: false })}
className="rounded-md border border-rose-500/60 bg-rose-500/10 px-4 py-1.5 text-sm font-bold text-rose-600 hover:bg-rose-500/20"> className="rounded-md border border-danger/60 bg-danger/10 px-4 py-1.5 text-sm font-bold text-danger hover:bg-danger/20">
{t('ctp.stopContest')} {t('ctp.stopContest')}
</button> </button>
) : ( ) : (
<button type="button" disabled={!session.code} <button type="button" disabled={!session.code}
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })} onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
className="rounded-md bg-amber-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-40"> className="rounded-md bg-warning px-4 py-1.5 text-sm font-bold text-warning-foreground hover:bg-warning disabled:opacity-40">
{t('ctp.startContest')} {t('ctp.startContest')}
</button> </button>
)} )}
+3 -3
View File
@@ -126,13 +126,13 @@ export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; o
{g.qsos.map((q, i) => { {g.qsos.map((q, i) => {
const checked = sel.has(q.id); const checked = sel.has(q.id);
return ( return (
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-rose-500/10')}> <tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-danger/10')}>
<td className="text-center py-1"> <td className="text-center py-1">
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} /> <input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
</td> </td>
<td className="px-2 py-1 font-mono whitespace-nowrap"> <td className="px-2 py-1 font-mono whitespace-nowrap">
{fmtDate(q.qso_date)} {fmtDate(q.qso_date)}
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-emerald-600">{t('dup.keep')}</span>} {i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-success">{t('dup.keep')}</span>}
</td> </td>
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td> <td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td> <td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
@@ -151,7 +151,7 @@ export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; o
<div className="flex items-center gap-2 px-5 py-3 border-t border-border"> <div className="flex items-center gap-2 px-5 py-3 border-t border-border">
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button> <button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0} <button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-1.5 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-40"> className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-bold text-destructive-foreground hover:bg-destructive disabled:opacity-40">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />} {busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
{t('dup.deleteSel', { n: sel.size })} {t('dup.deleteSel', { n: sel.size })}
</button> </button>
+2 -2
View File
@@ -25,8 +25,8 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
<Mic className="size-3.5 text-primary" /> <Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span> <span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} /> <span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>} {status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
<div className="flex-1" /> <div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}> <Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> {t('dvkp.stop')} <Square className="size-3" /> {t('dvkp.stop')}
+4 -4
View File
@@ -77,10 +77,10 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
</p> </p>
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center"> <div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
<Label className="text-sm">{t('frm.callsign')} <span className="text-red-500">*</span></Label> <Label className="text-sm">{t('frm.callsign')} <span className="text-destructive">*</span></Label>
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} /> <Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
<Label className="text-sm">{t('frm.locator')} <span className="text-red-500">*</span></Label> <Label className="text-sm">{t('frm.locator')} <span className="text-destructive">*</span></Label>
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} /> <Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
<Label className="text-sm">{t('frm.operator')}</Label> <Label className="text-sm">{t('frm.operator')}</Label>
@@ -104,10 +104,10 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')} {refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
</Button> </Button>
</div> </div>
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>} {refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-success' : 'text-destructive')}>{refsMsg}</div>}
</div> </div>
{err && <div className="mt-3 text-xs text-red-600">{err}</div>} {err && <div className="mt-3 text-xs text-destructive">{err}</div>}
<div className="mt-5 flex items-center justify-end gap-2"> <div className="mt-5 flex items-center justify-end gap-2">
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>} {!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
+15 -15
View File
@@ -79,7 +79,7 @@ function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max =
onChange={(e) => onChange(parseInt(e.target.value, 10))} onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default', className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full', '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')} '[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }} style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
/> />
); );
@@ -107,10 +107,10 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
}) { }) {
const onCls = { const onCls = {
emerald: 'bg-emerald-600 border-emerald-600 text-white', emerald: 'bg-success border-success text-success-foreground',
violet: 'bg-violet-600 border-violet-600 text-white', violet: 'bg-info border-info text-info-foreground',
cyan: 'bg-cyan-600 border-cyan-600 text-white', cyan: 'bg-info border-info text-info-foreground',
amber: 'bg-amber-500 border-amber-500 text-white', amber: 'bg-warning border-warning text-warning-foreground',
}[accent]; }[accent];
return ( return (
<button type="button" onClick={onClick} disabled={disabled} <button type="button" onClick={onClick} disabled={disabled}
@@ -273,8 +273,8 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider', <span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-slate-700 text-slate-300' !st.available ? 'bg-muted text-muted-foreground'
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}> : st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
<span className="size-2 rounded-full bg-current" /> <span className="size-2 rounded-full bg-current" />
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'} {!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
</span> </span>
@@ -304,13 +304,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<button type="button" disabled={off} <button type="button" disabled={off}
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))} onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}> st.tune ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
TUNE TUNE
</button> </button>
<button type="button" disabled={off} <button type="button" disabled={off}
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))} onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}> st.transmitting ? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50' : 'bg-card text-danger border-danger hover:bg-danger-muted')}>
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX <Power className="size-4 inline mr-1 -mt-0.5" /> MOX
</button> </button>
</div> </div>
@@ -319,7 +319,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
title={t('flxp.splitHint')} title={t('flxp.splitHint')}
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))} onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}> st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
SPLIT SPLIT
</button> </button>
{st.split && !!st.tx_freq_hz && ( {st.split && !!st.tx_freq_hz && (
@@ -414,7 +414,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
title={st.mute ? t('flxp.muted') : t('flxp.mute')} title={st.mute ? t('flxp.muted') : t('flxp.mute')}
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))} onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30', className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}> st.mute ? 'bg-destructive text-destructive-foreground' : 'text-muted-foreground hover:bg-muted')}>
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />} {st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button> </button>
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} /> <Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
@@ -495,7 +495,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<button type="button" disabled={off} <button type="button" disabled={off}
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))} onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}> st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'} {st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -506,13 +506,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
selector is disabled until connected (hover it for the error). */} selector is disabled until connected (hover it for the error). */}
{(pg.host || pg.connected) && ( {(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}> <label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} /> <span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-muted-foreground">{t('flxp.fan')}</span> <span className="text-muted-foreground">{t('flxp.fan')}</span>
<select <select
disabled={!pg.connected} disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()} value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }} onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40" className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
> >
<option value="STANDARD">{t('flxp.fanStandard')}</option> <option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option> <option value="CONTEST">{t('flxp.fanContest')}</option>
@@ -522,7 +522,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)} )}
<div className="flex-1" /> <div className="flex-1" />
{st.amp_fault && st.amp_fault !== 'NONE' && ( {st.amp_fault && st.amp_fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span> <span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
)} )}
</div> </div>
</Card> </Card>
+8 -8
View File
@@ -64,7 +64,7 @@ function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
onChange={(e) => onChange(parseInt(e.target.value, 10))} onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default', className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full', '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')} '[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }} style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
/> />
); );
@@ -90,7 +90,7 @@ function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label:
return ( return (
<button type="button" onClick={onClick} <button type="button" onClick={onClick}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors', className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}> on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label} {label}
</button> </button>
); );
@@ -457,7 +457,7 @@ function ScopePanadapter() {
</div> </div>
{on && ( {on && (
<div className="p-3"> <div className="p-3">
<div className="rounded-xl overflow-hidden ring-1 ring-sky-500/20 shadow-lg shadow-sky-500/5 bg-[#05070e]"> <div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
<canvas ref={canvasRef} onDoubleClick={onDblClick} <canvas ref={canvasRef} onDoubleClick={onDblClick}
className="w-full block cursor-crosshair" style={{ height: 140 }} /> className="w-full block cursor-crosshair" style={{ height: 140 }} />
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} /> <canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
@@ -551,13 +551,13 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm"> <div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider', <span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
tx ? 'bg-red-600 text-white' : 'bg-emerald-600 text-white')}> tx ? 'bg-destructive text-destructive-foreground' : 'bg-success text-success-foreground')}>
<span className={cn('size-2 rounded-full', tx ? 'bg-white animate-pulse' : 'bg-white/90')} /> <span className={cn('size-2 rounded-full', tx ? 'bg-card animate-pulse' : 'bg-card/90')} />
{tx ? 'TX' : 'RX'} {tx ? 'TX' : 'RX'}
</span> </span>
<span className="text-sm font-bold">{st.model || 'Icom'}</span> <span className="text-sm font-bold">{st.model || 'Icom'}</span>
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null} {st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
{st.split ? <span className="rounded-md bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</span> : null} {st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
</div> </div>
{/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */} {/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */}
<div className="hidden md:flex items-center"> <div className="hidden md:flex items-center">
@@ -609,13 +609,13 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<button type="button" onClick={toggleMox} <button type="button" onClick={toggleMox}
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors', className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
tx ? 'bg-red-600 border-red-600 text-white' : 'bg-card text-foreground border-border hover:bg-muted')}> tx ? 'bg-destructive border-destructive text-destructive-foreground' : 'bg-card text-foreground border-border hover:bg-muted')}>
{tx ? 'TX ON' : 'MOX'} {tx ? 'TX ON' : 'MOX'}
</button> </button>
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} /> <Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
<button type="button" onClick={tune} disabled={tuning} <button type="button" onClick={tune} disabled={tuning}
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors', className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
tuning ? 'bg-amber-500 border-amber-500 text-white animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}> tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
TUNE TUNE
</button> </button>
</div> </div>
+8 -27
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry,
type ColDef, type ColDef,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react'; import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
import { import {
@@ -24,27 +25,7 @@ import { netctl } from '@/../wailsjs/go/models';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({ const hamlogTheme = hamlogGridTheme;
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type Net = netctl.Net; type Net = netctl.Net;
type Station = netctl.Station; type Station = netctl.Station;
@@ -257,12 +238,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')} <Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
</Button> </Button>
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button> <Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}> <Button variant="ghost" size="sm" className="h-8 text-danger" disabled={!selId || isOpen} onClick={deleteNet}>
<Trash2 className="size-3.5" /> {t('ncp.delete')} <Trash2 className="size-3.5" /> {t('ncp.delete')}
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">{t('ncp.netOpenBadge')}</Badge>} {isOpen && <Badge className="bg-success text-success-foreground tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> · {t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong> {t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
@@ -273,7 +254,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
{/* ACTIVE USERS (left) */} {/* ACTIVE USERS (left) */}
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60"> <div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider"> <div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
{t('ncp.onAirActive')} {t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div> </div>
@@ -304,7 +285,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{/* NET USERS / roster (right) */} {/* NET USERS / roster (right) */}
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]"> <div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider"> <div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
{t('ncp.netUsersRoster')} {t('ncp.netUsersRoster')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
</div> </div>
@@ -327,7 +308,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}> <Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
<UserPlus className="size-3.5" /> {t('ncp.addContact')} <UserPlus className="size-3.5" /> {t('ncp.addContact')}
</Button> </Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}> <Button variant="ghost" size="sm" className="h-7 text-[11px] text-danger" disabled={!selId} onClick={removeSelectedRoster}>
<MinusCircle className="size-3.5" /> {t('ncp.remove')} <MinusCircle className="size-3.5" /> {t('ncp.remove')}
</Button> </Button>
{isOpen && ( {isOpen && (
+4 -4
View File
@@ -151,7 +151,7 @@ export function OperatingPanel({ bands, onError }: Props) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed"> <div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
{t('op.intro1')}<Star className="inline size-3 text-amber-500 fill-current align-text-bottom" />{t('op.intro2')} {t('op.intro1')}<Star className="inline size-3 text-warning fill-current align-text-bottom" />{t('op.intro2')}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -411,7 +411,7 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
className={cn( className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors', 'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
isDefault isDefault
? 'border-amber-400 bg-amber-50 shadow-sm' ? 'border-warning-border bg-warning-muted shadow-sm'
: enabled : enabled
? 'border-primary/30 bg-primary/5' ? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-muted/30 text-muted-foreground' : 'border-border/50 bg-muted/30 text-muted-foreground'
@@ -430,8 +430,8 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
className={cn( className={cn(
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors', 'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
isDefault isDefault
? 'bg-amber-400 text-white' ? 'bg-warning text-warning-foreground'
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700', : 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-warning hover:text-warning',
)} )}
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')} title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
> >
+8 -17
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react'; import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community'; import { AllCommunityModule, ModuleRegistry, type ColDef } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -16,15 +16,6 @@ import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
// Warm theme matching the other grids (Recent QSOs / Cluster).
const qslTheme = themeQuartz.withParams({
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
});
type UploadRow = { type UploadRow = {
id: number; qso_date: string; callsign: string; id: number; qso_date: string; callsign: string;
band: string; mode: string; country: string; status: string; band: string; mode: string; country: string; status: string;
@@ -399,7 +390,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{/* Content: log OR results grid */} {/* Content: log OR results grid */}
<div className="flex-1 overflow-auto px-3 py-2 min-h-0"> <div className="flex-1 overflow-auto px-3 py-2 min-h-0">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>} {error && <div className="text-xs text-danger mb-2">{error}</div>}
{service === 'paper' ? ( {service === 'paper' ? (
paperRows.length === 0 ? ( paperRows.length === 0 ? (
@@ -425,7 +416,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>} {potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
{potaRes && ( {potaRes && (
<> <>
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800"> <div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })} {t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
{potaRes.skipped_other_call > 0 && ( {potaRes.skipped_other_call > 0 && (
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</> <>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
@@ -469,8 +460,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div> <div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
) : logLines.map((l, i) => ( ) : logLines.map((l, i) => (
<div key={i} className={cn( <div key={i} className={cn(
/FAIL|failed|error/i.test(l) ? 'text-rose-700' /FAIL|failed|error/i.test(l) ? 'text-danger'
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700' : /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-success'
: 'text-foreground/90')}>{l}</div> : 'text-foreground/90')}>{l}</div>
))} ))}
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>} {busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
@@ -498,9 +489,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<td className="py-1 px-2">{c.mode}</td> <td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td> <td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2"> <td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">{t('qslm.newDxcc')}</span> {c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">{t('qslm.newBand')}</span> : c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">{t('qslm.newSlot')}</span> : c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
: <span className="text-muted-foreground/50"></span>} : <span className="text-muted-foreground/50"></span>}
</td> </td>
</tr> </tr>
+11 -11
View File
@@ -75,7 +75,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }} onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
> >
<RefreshCw className="size-4 text-sky-600" /> <RefreshCw className="size-4 text-info" />
<span>{t('qctx.updateQrz')}</span> <span>{t('qctx.updateQrz')}</span>
</button> </button>
{onUpdateFromClublog && ( {onUpdateFromClublog && (
@@ -83,7 +83,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }} onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
> >
<BadgeCheck className="size-4 text-violet-600" /> <BadgeCheck className="size-4 text-info" />
<span>{t('qctx.updateClublog')}</span> <span>{t('qctx.updateClublog')}</span>
</button> </button>
)} )}
@@ -96,7 +96,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendEQSL(menu.ids); onClose(); }} onClick={() => { onSendEQSL(menu.ids); onClose(); }}
> >
<Mail className="size-4 text-amber-600" /> <Mail className="size-4 text-warning" />
<span>{t('qctx.sendQslEmail')}</span> <span>{t('qctx.sendQslEmail')}</span>
</button> </button>
)} )}
@@ -105,7 +105,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }} onClick={() => { onSendRecording(menu.ids); onClose(); }}
> >
<Mail className="size-4 text-rose-600" /> <Mail className="size-4 text-danger" />
<span>{t('qctx.sendRecording')}</span> <span>{t('qctx.sendRecording')}</span>
</button> </button>
)} )}
@@ -119,7 +119,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onBulkEdit(menu.ids); onClose(); }} onClick={() => { onBulkEdit(menu.ids); onClose(); }}
> >
<PencilLine className="size-4 text-indigo-600" /> <PencilLine className="size-4 text-info" />
<span>{t('qctx.bulkEdit', { n })}</span> <span>{t('qctx.bulkEdit', { n })}</span>
</button> </button>
</> </>
@@ -133,7 +133,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportSelected(menu.ids); onClose(); }} onClick={() => { onExportSelected(menu.ids); onClose(); }}
> >
<FileDown className="size-4 text-sky-600" /> <FileDown className="size-4 text-info" />
<span>{t('qctx.exportSelectedAdif', { n })}</span> <span>{t('qctx.exportSelectedAdif', { n })}</span>
</button> </button>
)} )}
@@ -142,7 +142,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportFiltered(); onClose(); }} onClick={() => { onExportFiltered(); onClose(); }}
> >
<FileDown className="size-4 text-violet-600" /> <FileDown className="size-4 text-info" />
<span>{t('qctx.exportFilteredAdif')}</span> <span>{t('qctx.exportFilteredAdif')}</span>
</button> </button>
)} )}
@@ -151,7 +151,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }} onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
> >
<FileDown className="size-4 text-amber-600" /> <FileDown className="size-4 text-warning" />
<span>{t('qctx.exportSelectedCabrillo', { n })}</span> <span>{t('qctx.exportSelectedCabrillo', { n })}</span>
</button> </button>
)} )}
@@ -160,7 +160,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportCabrilloFiltered(); onClose(); }} onClick={() => { onExportCabrilloFiltered(); onClose(); }}
> >
<FileDown className="size-4 text-amber-700" /> <FileDown className="size-4 text-warning" />
<span>{t('qctx.exportFilteredCabrillo')}</span> <span>{t('qctx.exportFilteredCabrillo')}</span>
</button> </button>
)} )}
@@ -176,7 +176,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }} onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
> >
<Upload className="size-4 text-emerald-600" /> <Upload className="size-4 text-success" />
<span>{t('qctx.sendTo', { name: target.name })}</span> <span>{t('qctx.sendTo', { name: target.name })}</span>
</button> </button>
))} ))}
@@ -187,7 +187,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<> <>
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
<button <button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-danger hover:bg-danger-muted"
onClick={() => { onDelete(menu.ids); onClose(); }} onClick={() => { onDelete(menu.ids); onClose(); }}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
+4 -4
View File
@@ -81,10 +81,10 @@ function StatusCell({ value }: { value?: string }) {
return <span className="block text-center text-[11px] text-muted-foreground"></span>; return <span className="block text-center text-[11px] text-muted-foreground"></span>;
} }
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo'); const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
const cls = v === 'Y' ? 'bg-emerald-600 text-white' const cls = v === 'Y' ? 'bg-success text-success-foreground'
: v === 'R' ? 'bg-orange-400 text-white' : v === 'R' ? 'bg-warning text-warning-foreground'
: v === 'I' ? 'bg-stone-400 text-white' : v === 'I' ? 'bg-muted-foreground text-background'
: 'bg-amber-400 text-amber-950'; : 'bg-warning text-warning-foreground';
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>; return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
} }
+4 -23
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react'; import { Columns3, FilterX } from 'lucide-react';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
@@ -20,28 +21,8 @@ import { useI18n } from '@/lib/i18n';
// virtual-scroll — everything we want out of the box for a logbook table. // virtual-scroll — everything we want out of the box for a logbook table.
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
// Custom Quartz theme tuned to match OpsLog's warm palette. // Shared theme (follows the app palette); this table runs slightly taller rows.
const hamlogTheme = themeQuartz.withParams({ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34 });
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 32,
headerHeight: 34,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type Props = { type Props = {
rows: QSOForm[]; rows: QSOForm[];
+5 -5
View File
@@ -75,22 +75,22 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Compass className="size-4 text-primary shrink-0" /> <Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} <span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} /> title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" /> <div className="flex-1" />
{pattern && ( {pattern && (
<span <span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide', className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-amber-200 text-amber-900' pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
: pattern === 'bi' ? 'bg-sky-200 text-sky-900' : pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
: 'bg-emerald-200 text-emerald-900')} : 'bg-success-muted text-success-muted-foreground')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom' title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways' : pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}> : 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'} {pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span> </span>
)} )}
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums"> <span className="font-mono text-sm font-bold text-success tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'} {headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span> </span>
{onClose && ( {onClose && (
+1 -1
View File
@@ -144,7 +144,7 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
</div> </div>
)} )}
{error && <div className="text-xs text-rose-600">{error}</div>} {error && <div className="text-xs text-danger">{error}</div>}
</div> </div>
<DialogFooter> <DialogFooter>
+90 -25
View File
@@ -59,6 +59,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel'; import { OperatingPanel } from '@/components/OperatingPanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel'; import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
@@ -333,6 +334,68 @@ function TreeNodeView({
// ===== Section content panels ===== // ===== 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 }) { function SectionHeader({ title, hint }: { title: string; hint?: string }) {
return ( return (
<header className="mb-4"> <header className="mb-4">
@@ -646,7 +709,7 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
</p> </p>
</div> </div>
{rxList.length === 0 && ( {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. No antennas reported yet make sure the FlexRadio is connected (CAT interface), then reopen this panel.
</div> </div>
)} )}
@@ -1474,7 +1537,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
disabled={!current} disabled={!current}
/> />
{current?.is_active && ( {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')} {t('prof.active')}
</span> </span>
)} )}
@@ -1577,7 +1640,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{testing ? t('lk.testing') : t('lk.test')} {testing ? t('lk.testing') : t('lk.test')}
</Button> </Button>
</td> </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} {test?.msg}
</td> </td>
</tr> </tr>
@@ -2143,7 +2206,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className={cn( <div className={cn(
'text-xs rounded-md p-2.5 border', 'text-xs rounded-md p-2.5 border',
ubTest.ok 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', : 'bg-destructive/10 text-destructive border-destructive/30',
)}> )}>
{ubTest.msg} {ubTest.msg}
@@ -2278,7 +2341,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className={cn( <div className={cn(
'text-xs rounded-md p-2.5 border', 'text-xs rounded-md p-2.5 border',
rotatorTest.ok 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', : 'bg-destructive/10 text-destructive border-destructive/30',
)}> )}>
{rotatorTest.msg} {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). 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> </p>
{(!catCfg.enabled || catCfg.backend !== 'icom') && ( {(!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 aria-hidden></span>
<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. 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"> <td className="px-3 py-2 font-medium">
{s.name} {s.name}
{isMaster && s.enabled && ( {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>
<td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td> <td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={cn( <span className={cn(
'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border', '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 === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
state === 'connecting' || state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' : state === 'connecting' || state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' : state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border', 'bg-muted text-muted-foreground border-border',
)}> )}>
{state.toUpperCase()} {state.toUpperCase()}
@@ -2555,7 +2618,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<td className="px-2 py-2"> <td className="px-2 py-2">
<div className="flex items-center gap-0.5 justify-end"> <div className="flex items-center gap-0.5 justify-end">
{state === 'connected' || state === 'connecting' || state === 'reconnecting' ? ( {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(); }} onClick={async () => { await DisconnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
title={t('clu.disconnect')}><Power className="size-3.5" /></Button> title={t('clu.disconnect')}><Power className="size-3.5" /></Button>
) : ( ) : (
@@ -2874,8 +2937,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className={cn( <div className={cn(
'text-xs px-3 py-2 rounded-md border', 'text-xs px-3 py-2 rounded-md border',
backupResult.ok backupResult.ok
? 'bg-emerald-50 border-emerald-300 text-emerald-800' ? 'bg-success-muted border-success-border text-success-muted-foreground'
: 'bg-rose-50 border-rose-300 text-rose-800', : 'bg-danger-muted border-danger-border text-danger-muted-foreground',
)}> )}>
{backupResult.msg} {backupResult.msg}
</div> </div>
@@ -3083,7 +3146,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {qrzTesting ? t('es.testing') : t('es.testConn')} <UploadCloud className="size-3.5" /> {qrzTesting ? t('es.testing') : t('es.testConn')}
</Button> </Button>
{qrzTest && ( {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} {qrzTest.msg}
</span> </span>
)} )}
@@ -3147,7 +3210,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {clublogTesting ? t('es.testing') : t('es.testConn')} <UploadCloud className="size-3.5" /> {clublogTesting ? t('es.testing') : t('es.testConn')}
</Button> </Button>
{clublogTest && ( {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} {clublogTest.msg}
</span> </span>
)} )}
@@ -3206,7 +3269,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {hrdlogTesting ? t('es.testing') : t('es.testConn')} <UploadCloud className="size-3.5" /> {hrdlogTesting ? t('es.testing') : t('es.testConn')}
</Button> </Button>
{hrdlogTest && ( {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} {hrdlogTest.msg}
</span> </span>
)} )}
@@ -3272,7 +3335,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {eqslTesting ? t('es.testing') : t('es.testConn')} <UploadCloud className="size-3.5" /> {eqslTesting ? t('es.testing') : t('es.testConn')}
</Button> </Button>
{eqslTest && ( {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} {eqslTest.msg}
</span> </span>
)} )}
@@ -3389,7 +3452,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<UploadCloud className="size-3.5" /> {lotwTesting ? t('es.testing') : t('es.testConn')} <UploadCloud className="size-3.5" /> {lotwTesting ? t('es.testing') : t('es.testConn')}
</Button> </Button>
{lotwTest && ( {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} {lotwTest.msg}
</span> </span>
)} )}
@@ -3423,7 +3486,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
{potaResult && ( {potaResult && (
<div className={cn('text-xs rounded-md px-3 py-2 border', <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} {potaResult.msg}
</div> </div>
)} )}
@@ -3514,14 +3577,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}}> }}>
{t('db.saveSwitch')} {t('db.saveSwitch')}
</Button> </Button>
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>} {restartMsg && <span className="text-[11px] text-success">{restartMsg}</span>}
</div> </div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */} {/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && ( {backendStatus && (
<div className="max-w-2xl mb-4"> <div className="max-w-2xl mb-4">
{backendStatus.fallback ? ( {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')} {t('db.fallback')}
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div> <div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</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"> <div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dbSettings.path || '—'} {dbSettings.path || '—'}
{dbSettings.is_custom {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>} : <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
</div> </div>
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</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> </div>
{dbMsg && ( {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> <span>{dbMsg}</span>
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>{t('db.quitNow')}</Button> <Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>{t('db.quitNow')}</Button>
</div> </div>
@@ -3826,6 +3889,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</div> </div>
<ThemeSelector />
<label className="flex items-center gap-2 text-sm cursor-pointer"> <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'); }} /> <Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
{t('gen.autofocusWB')} {t('gen.autofocusWB')}
@@ -3858,8 +3923,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Passwords encrypted{' '} Passwords encrypted{' '}
{secret.unlocked {secret.unlocked
? <span className="text-emerald-700 font-medium"> unlocked</span> ? <span className="text-success font-medium"> unlocked</span>
: <span className="text-amber-600 font-medium"> locked (unlock at launch)</span>}. : <span className="text-warning font-medium"> locked (unlock at launch)</span>}.
</p> </p>
{secret.unlocked && ( {secret.unlocked && (
<> <>
+3 -3
View File
@@ -37,15 +37,15 @@ export function ShutdownProgress() {
) : steps.map((s) => ( ) : steps.map((s) => (
<div key={s.id} className="flex items-start gap-2 text-sm"> <div key={s.id} className="flex items-start gap-2 text-sm">
<div className="mt-0.5 w-4 flex items-center justify-center"> <div className="mt-0.5 w-4 flex items-center justify-center">
{s.status === 'done' && <CheckCircle2 className="size-4 text-emerald-600" />} {s.status === 'done' && <CheckCircle2 className="size-4 text-success" />}
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />} {s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
{s.status === 'error' && <XCircle className="size-4 text-rose-600" />} {s.status === 'error' && <XCircle className="size-4 text-danger" />}
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />} {s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className={ <div className={
s.status === 'done' ? 'text-foreground' s.status === 'done' ? 'text-foreground'
: s.status === 'error' ? 'text-rose-700 font-medium' : s.status === 'error' ? 'text-danger font-medium'
: s.status === 'pending' ? 'text-muted-foreground' : s.status === 'pending' ? 'text-muted-foreground'
: 'text-foreground font-medium' : 'text-foreground font-medium'
}> }>
+5 -5
View File
@@ -103,7 +103,7 @@ export function WinkeyerPanel({
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : 'WinKeyer'} {source === 'icom' ? 'Icom CW' : 'WinKeyer'}
</span> </span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')} <span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} /> title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" /> <div className="flex-1" />
{source === 'icom' ? ( {source === 'icom' ? (
@@ -142,7 +142,7 @@ export function WinkeyerPanel({
<Label className="text-xs w-8 shrink-0">TX</Label> <Label className="text-xs w-8 shrink-0">TX</Label>
<div className={cn( <div className={cn(
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate', 'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
status.busy ? 'text-emerald-700' : 'text-muted-foreground', status.busy ? 'text-success' : 'text-muted-foreground',
)}> )}>
{sent || <span className="opacity-50"></span>} {sent || <span className="opacity-50"></span>}
{status.busy && <span className="ml-0.5 animate-pulse"></span>} {status.busy && <span className="ml-0.5 animate-pulse"></span>}
@@ -174,7 +174,7 @@ export function WinkeyerPanel({
</button> </button>
))} ))}
</div> </div>
{breakIn === 0 && <span className="text-[10px] text-amber-600">{t('wkp.bkOffWarn')}</span>} {breakIn === 0 && <span className="text-[10px] text-warning">{t('wkp.bkOffWarn')}</span>}
</div> </div>
)} )}
@@ -225,7 +225,7 @@ export function WinkeyerPanel({
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} /> value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
<span className="text-[9px] text-muted-foreground">sec</span> <span className="text-[9px] text-muted-foreground">sec</span>
</div> </div>
{autoCall && <span className="text-[10px] text-amber-600/80">{t('wkp.loopHint')}</span>} {autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
</div> </div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */} {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
@@ -247,7 +247,7 @@ export function WinkeyerPanel({
</button> </button>
))} ))}
</div> </div>
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>} {status.error && <div className="text-[11px] text-danger">{status.error}</div>}
</div> </div>
</section> </section>
); );
+3 -22
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX, Star } from 'lucide-react'; import { Columns3, FilterX, Star } from 'lucide-react';
import { import {
@@ -19,27 +20,7 @@ import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({ const hamlogTheme = hamlogGridTheme;
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type WorkedEntry = QSOForm; // entries are now full QSO records type WorkedEntry = QSOForm; // entries are now full QSO records
@@ -233,7 +233,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
<DialogContent className="max-w-[1260px]"> <DialogContent className="max-w-[1260px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Sparkles className="size-5 text-amber-500" /> <Sparkles className="size-5 text-warning" />
QSL card designer QSL card designer
{view !== 'home' && ( {view !== 'home' && (
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs" <Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
@@ -246,7 +246,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5"> <div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
{error && ( {error && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div> <div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
)} )}
{view === 'home' && ( {view === 'home' && (
@@ -284,7 +284,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>} : <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
<div className="mt-1.5 flex items-center justify-between gap-1"> <div className="mt-1.5 flex items-center justify-between gap-1">
<span className="truncate text-sm font-medium"> <span className="truncate text-sm font-medium">
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />} {t.is_default && <Star className="mr-1 inline size-3.5 fill-warning text-warning" />}
{t.name} {t.name}
</span> </span>
<span className="flex shrink-0 gap-0.5"> <span className="flex shrink-0 gap-0.5">
@@ -299,7 +299,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
</Button> </Button>
)} )}
<Button variant="ghost" size="sm" <Button variant="ghost" size="sm"
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`} className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-destructive' : 'w-7'}`}
title="Delete" onClick={() => removeTemplate(t.id)}> title="Delete" onClick={() => removeTemplate(t.id)}>
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />} {deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
</Button> </Button>
@@ -323,7 +323,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
{proposals.map((p, i) => ( {proposals.map((p, i) => (
<button <button
key={i} key={i}
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400" className="rounded-md border-2 border-transparent p-1 transition hover:border-info"
onClick={() => openEditor(0, p.template, p.model, p.assets)} onClick={() => openEditor(0, p.template, p.model, p.assets)}
> >
<CardPreview model={p.model} assets={p.assets} width={352} /> <CardPreview model={p.model} assets={p.assets} width={352} />
@@ -75,13 +75,13 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogContent className="max-w-[820px]"> <DialogContent className="max-w-[820px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail <Mail className="size-5 text-danger" /> Send OpsLog QSL by e-mail
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3 px-6 py-5"> <div className="space-y-3 px-6 py-5">
{error && ( {error && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div> <div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
)} )}
{open && !templateId && !error && ( {open && !templateId && !error && (
@@ -107,7 +107,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogFooter> <DialogFooter>
{sent ? ( {sent ? (
<div className="flex items-center gap-2 text-sm text-emerald-600"> <div className="flex items-center gap-2 text-sm text-success">
<CheckCircle2 className="size-4" /> OpsLog QSL sent. <CheckCircle2 className="size-4" /> OpsLog QSL sent.
<Button variant="outline" size="sm" onClick={onClose}>Close</Button> <Button variant="outline" size="sm" onClick={onClose}>Close</Button>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'z-50 overflow-hidden rounded-md bg-stone-900 px-2.5 py-1.5 text-xs text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className, className,
)} )}
{...props} {...props}
+30
View File
@@ -0,0 +1,30 @@
import { themeQuartz } from 'ag-grid-community';
// Shared AG-Grid theme for every logbook table (Recent QSOs, Worked-before,
// Cluster, Net, QSL Manager). Colours are wired to the app's semantic CSS
// variables (see style.css) instead of hardcoded hex, so the grids follow the
// active theme (light-warm / dark-warm / graphite / high-contrast) at runtime —
// flipping <html data-theme> re-resolves these var() references with no
// re-render. Keep this the single source of truth; per-grid tweaks (e.g. row
// height) chain a `.withParams({...})` on top.
export const hamlogGridTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: 'var(--card)',
foregroundColor: 'var(--foreground)',
headerBackgroundColor: 'var(--muted)',
headerTextColor: 'var(--muted-foreground)',
headerFontWeight: 600,
oddRowBackgroundColor: 'color-mix(in srgb, var(--muted) 40%, var(--card))',
rowHoverColor: 'color-mix(in srgb, var(--accent) 55%, var(--card))',
selectedRowBackgroundColor: 'var(--accent)',
borderColor: 'var(--border)',
rowBorder: { color: 'var(--border)', width: 1 },
columnBorder: { color: 'var(--border)', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: 'var(--primary)',
iconSize: 12,
});
+6
View File
@@ -43,6 +43,9 @@ const en: Dict = {
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.', 'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français', 'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.', 'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
// Settings navigation (sidebar groups + section names) // Settings navigation (sidebar groups + section names)
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', 'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions', 'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
@@ -239,6 +242,9 @@ const fr: Dict = {
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.', 'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.", 'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes', 'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération", 'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
+72
View File
@@ -0,0 +1,72 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { writeUiPref } from './uiPref';
// Theme system. Each choice maps to a `data-theme` value on <html> that the
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
// preference. The choice is persisted (localStorage + portable UI pref, so it
// travels with the data/ folder like the language).
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
// Selectable, concrete themes (excludes 'auto') in display order.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
];
export const LS_KEY = 'opslog.theme';
const DEFAULT: ThemeChoice = 'light-warm';
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
function systemDark(): boolean {
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
}
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
function resolve(choice: ThemeChoice): string {
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
return choice;
}
function readStored(): ThemeChoice {
try {
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
if (v && ALL.includes(v)) return v;
} catch { /* private mode */ }
return DEFAULT;
}
// Stamp the resolved theme onto <html data-theme>.
export function applyThemeToDom(choice: ThemeChoice): void {
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
}
// Call before the first render (main.tsx) so there is no flash of the default
// palette while React boots.
export function initTheme(): void { applyThemeToDom(readStored()); }
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
export function useTheme(): Ctx { return useContext(ThemeCtx); }
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
const setTheme = useCallback((t: ThemeChoice) => {
setThemeState(t);
applyThemeToDom(t);
writeUiPref(LS_KEY, t);
}, []);
// While in 'auto', re-resolve when the OS light/dark preference flips.
useEffect(() => {
if (theme !== 'auto') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => applyThemeToDom('auto');
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [theme]);
// Keep the DOM attribute in sync with state.
useEffect(() => { applyThemeToDom(theme); }, [theme]);
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
}
+1
View File
@@ -10,6 +10,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
// Keys that must travel with data/ (DB is the portable source of truth; the // Keys that must travel with data/ (DB is the portable source of truth; the
// localStorage copy is just a fast, synchronous cache). // localStorage copy is just a fast, synchronous cache).
const PORTABLE_KEYS = [ const PORTABLE_KEYS = [
'opslog.theme', // UI colour theme (light-warm / dark-warm / graphite / high-contrast / auto)
'hamlog.qsoLimit', // QSO list page size 'hamlog.qsoLimit', // QSO list page size
'bandmap.side', // band map docked left / right 'bandmap.side', // band map docked left / right
'bandmap.show', // band map open / closed 'bandmap.show', // band map open / closed
+6
View File
@@ -4,6 +4,7 @@ import './style.css'
import App from './App' import App from './App'
import { syncPortablePrefs } from './lib/uiPref' import { syncPortablePrefs } from './lib/uiPref'
import { I18nProvider } from './lib/i18n' import { I18nProvider } from './lib/i18n'
import { ThemeProvider, initTheme } from './lib/theme'
const container = document.getElementById('root') const container = document.getElementById('root')
@@ -13,10 +14,15 @@ const root = createRoot(container!)
// app's synchronous reads see the values copied along with the data/ folder. // app's synchronous reads see the values copied along with the data/ folder.
// Render regardless of the outcome so a backend hiccup never blocks startup. // Render regardless of the outcome so a backend hiccup never blocks startup.
syncPortablePrefs().finally(() => { syncPortablePrefs().finally(() => {
// Stamp the persisted theme onto <html> before render so the correct
// palette is applied immediately (portable pref hydrated just above).
initTheme()
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<I18nProvider> <I18nProvider>
<ThemeProvider>
<App/> <App/>
</ThemeProvider>
</I18nProvider> </I18nProvider>
</React.StrictMode> </React.StrictMode>
) )
+349 -35
View File
@@ -3,43 +3,358 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
/* ===== Warm taupe & burnt-orange palette ===== /* ============================================================================
A step darker than pure cream: the body is a soft taupe so white-ish THEME SYSTEM
cards lift off the surface, giving real depth without going dark. ----------------------------------------------------------------------------
Borders are more defined; muted surfaces sit between bg and card so Every color is a semantic CSS variable declared per theme below. The
secondary chrome (toolbars, table headers) reads as quietly recessed. `@theme inline` block points Tailwind's color utilities (bg-*, text-*,
*/ border-*, ring-*, ) directly at those variables, so switching the
@theme { `data-theme` attribute on <html> reskins the whole UI at runtime with no
--color-background: #e8dfc9; /* warm taupe — deeper than cream */ rebuild. Four themes ship: two light-neutral surfaces and status families
--color-foreground: #1c1917; /* stone-900 */ for OK / warning / caution / danger / info that adapt to each surface.
--color-card: #faf6ea; /* lifted off-white cream */ Semantic status vocabulary (use these, NOT raw palette colors):
--color-card-foreground: #1c1917; success worked / confirmed / data / OK (was emerald)
warning new-band / attention (was amber)
caution new-mode / new-slot / lighter alert (was yellow)
danger new / split / negative (was rose)
info informational (was sky/blue)
destructive hard errors / live REC / delete (was red)
Each family has: base (dots/bars/solid), -foreground (text on base),
-muted (soft pill fill), -muted-foreground (text on pill), -border.
========================================================================= */
--color-popover: #faf6ea; /* ---- Theme 1: Warm light (default) — warm taupe & burnt orange ---------- */
--color-popover-foreground: #1c1917; :root,
[data-theme="light-warm"] {
--background: #e8dfc9; /* warm taupe — deeper than cream */
--foreground: #1c1917; /* stone-900 */
--card: #faf6ea; /* lifted off-white cream */
--card-foreground: #1c1917;
--popover: #faf6ea;
--popover-foreground: #1c1917;
--primary: #b8410c; /* burnt orange, slightly deeper */
--primary-foreground: #fff7ed;
--secondary: #ddd2b8;
--secondary-foreground: #1c1917;
--muted: #ddd2b8; /* toolbars / table headers */
--muted-foreground: #44403c; /* stone-700 — readable on muted */
--accent: #f8d6a9; /* warm amber-cream */
--accent-foreground: #7c2d12; /* orange-900 */
--destructive: #a51c1c;
--destructive-foreground: #ffffff;
--destructive-muted: #fef2f2;
--destructive-muted-foreground: #b91c1c;
--border: #c8b994; /* deeper warm beige border */
--input: #c8b994;
--ring: #d97706; /* amber-600 focus ring */
--color-primary: #b8410c; /* burnt orange, slightly deeper */ --success: #16a34a;
--color-primary-foreground: #fff7ed; --success-foreground: #ffffff;
--success-muted: #dcfce7;
--success-muted-foreground: #166534;
--success-border: #86efac;
--color-secondary: #ddd2b8; /* a touch under the bg */ --warning: #d97706;
--color-secondary-foreground: #1c1917; --warning-foreground: #ffffff;
--warning-muted: #fef3c7;
--warning-muted-foreground: #92400e;
--warning-border: #fcd34d;
--color-muted: #ddd2b8; /* toolbars / table headers */ --caution: #ca8a04;
--color-muted-foreground: #44403c; /* stone-700 — readable on muted */ --caution-foreground: #1c1917;
--caution-muted: #fef9c3;
--caution-muted-foreground: #854d0e;
--caution-border: #fde047;
--color-accent: #f8d6a9; /* warm amber-cream */ --danger: #e11d48;
--color-accent-foreground: #7c2d12; /* orange-900 */ --danger-foreground: #ffffff;
--danger-muted: #ffe4e6;
--danger-muted-foreground: #9f1239;
--danger-border: #fda4af;
--color-destructive: #a51c1c; --info: #0284c7;
--color-destructive-foreground: #ffffff; --info-foreground: #ffffff;
--info-muted: #e0f2fe;
--info-muted-foreground: #075985;
--info-border: #7dd3fc;
--color-border: #c8b994; /* deeper warm beige border */ /* Band/mode matrix (BandSlotGrid) — original emerald/indigo/stone ramp. */
--color-input: #c8b994; --mx-call-conf: #15803d; /* call confirmed */
--color-ring: #d97706; /* amber-600 focus ring */ --mx-call-work: #6ee7b7; /* call worked */
--mx-dx-conf: #3730a3; /* entity confirmed*/
--mx-dx-work: #a5b4fc; /* entity worked */
--mx-none: #e7e5e4; /* never worked */
--color-ok: #15803d; --scrollbar-thumb: #b8a880;
--color-warn: #b45309; --scrollbar-thumb-hover: #968455;
--card-shadow: 0 1px 2px rgba(28, 25, 23, 0.05), 0 0 0 1px rgba(28, 25, 23, 0.02);
color-scheme: light;
}
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
[data-theme="dark-warm"] {
--background: #221d18;
--foreground: #ede6d8;
--card: #2e2820;
--card-foreground: #ede6d8;
--popover: #2e2820;
--popover-foreground: #ede6d8;
--primary: #e07a2e; /* brighter burnt orange for dark */
--primary-foreground: #1a1512;
--secondary: #3a3229;
--secondary-foreground: #ede6d8;
--muted: #352e26;
--muted-foreground: #bcae9a;
--accent: #4a3a28;
--accent-foreground: #f8d6a9;
--destructive: #e5484d;
--destructive-foreground: #1a1512;
--destructive-muted: #3a1414;
--destructive-muted-foreground: #fca5a5;
--border: #473e33;
--input: #473e33;
--ring: #e8853a;
--success: #34d399;
--success-foreground: #0e2a20;
--success-muted: #123024;
--success-muted-foreground: #6ee7b7;
--success-border: #1e5540;
--warning: #fbbf24;
--warning-foreground: #2a1f08;
--warning-muted: #3a2e10;
--warning-muted-foreground: #fcd34d;
--warning-border: #5c4a18;
--caution: #facc15;
--caution-foreground: #2a2607;
--caution-muted: #38330f;
--caution-muted-foreground: #fde047;
--caution-border: #574f18;
--danger: #fb7185;
--danger-foreground: #2a0d13;
--danger-muted: #3a1720;
--danger-muted-foreground: #fda4af;
--danger-border: #5c2530;
--info: #38bdf8;
--info-foreground: #08222e;
--info-muted: #0c2a3a;
--info-muted-foreground: #7dd3fc;
--info-border: #164a5c;
/* Matrix: bright confirmed, medium-solid worked (clearly visible on dark),
warm-gray not-worked lifted off the card. */
--mx-call-conf: #22c55e;
--mx-call-work: #2f7d55;
--mx-dx-conf: #6366f1;
--mx-dx-work: #3f3f8a;
--mx-none: #453d33;
--scrollbar-thumb: #5c5044;
--scrollbar-thumb-hover: #7a6a58;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 240, 220, 0.04);
color-scheme: dark;
}
/* ---- Theme 3: Graphite dark — cool slate, technical look --------------- */
[data-theme="dark-graphite"] {
--background: #16181d;
--foreground: #e6e8ec;
--card: #1f232b;
--card-foreground: #e6e8ec;
--popover: #1f232b;
--popover-foreground: #e6e8ec;
--primary: #f97316; /* orange-500 pops on slate */
--primary-foreground: #0a0c10;
--secondary: #272c35;
--secondary-foreground: #e6e8ec;
--muted: #232830;
--muted-foreground: #9aa2b1;
--accent: #2a3240;
--accent-foreground: #cbd5e5;
--destructive: #ef4444;
--destructive-foreground: #0a0c10;
--destructive-muted: #331414;
--destructive-muted-foreground: #fca5a5;
--border: #2e343f;
--input: #2e343f;
--ring: #fb8c3c;
--success: #34d399;
--success-foreground: #06231a;
--success-muted: #0f2e26;
--success-muted-foreground: #6ee7b7;
--success-border: #17513f;
--warning: #fbbf24;
--warning-foreground: #241a06;
--warning-muted: #33290f;
--warning-muted-foreground: #fcd34d;
--warning-border: #4f3f16;
--caution: #facc15;
--caution-foreground: #242006;
--caution-muted: #322e0e;
--caution-muted-foreground: #fde047;
--caution-border: #4c4416;
--danger: #fb7185;
--danger-foreground: #260a11;
--danger-muted: #34141d;
--danger-muted-foreground: #fda4af;
--danger-border: #532430;
--info: #38bdf8;
--info-foreground: #061f2b;
--info-muted: #0b2938;
--info-muted-foreground: #7dd3fc;
--info-border: #124659;
/* Matrix: bright confirmed, medium-solid worked, cool-gray not-worked. */
--mx-call-conf: #22c55e;
--mx-call-work: #2f7d55;
--mx-dx-conf: #6366f1;
--mx-dx-work: #3f3f8a;
--mx-none: #333a45;
--scrollbar-thumb: #3a4150;
--scrollbar-thumb-hover: #4e5768;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(230, 232, 236, 0.04);
color-scheme: dark;
}
/* ---- Theme 4: High contrast — pure black / white, max legibility ------- */
[data-theme="high-contrast"] {
--background: #000000;
--foreground: #ffffff;
--card: #0d0d0d;
--card-foreground: #ffffff;
--popover: #0d0d0d;
--popover-foreground: #ffffff;
--primary: #ff7a1a;
--primary-foreground: #000000;
--secondary: #1a1a1a;
--secondary-foreground: #ffffff;
--muted: #141414;
--muted-foreground: #d4d4d4;
--accent: #262626;
--accent-foreground: #ffffff;
--destructive: #ff4d4d;
--destructive-foreground: #000000;
--destructive-muted: #2a0808;
--destructive-muted-foreground: #ff9999;
--border: #3f3f3f;
--input: #3f3f3f;
--ring: #ffab5e;
--success: #22e37a;
--success-foreground: #002313;
--success-muted: #06231a;
--success-muted-foreground: #6affb0;
--success-border: #0f6b4a;
--warning: #ffcc33;
--warning-foreground: #201900;
--warning-muted: #2a2205;
--warning-muted-foreground: #ffe08a;
--warning-border: #6b5510;
--caution: #ffe14d;
--caution-foreground: #201d00;
--caution-muted: #2a2705;
--caution-muted-foreground: #fff08a;
--caution-border: #6b6210;
--danger: #ff6b7d;
--danger-foreground: #23000a;
--danger-muted: #2a0a12;
--danger-muted-foreground: #ffb3bd;
--danger-border: #6b1f2c;
--info: #4dc4ff;
--info-foreground: #001a26;
--info-muted: #052330;
--info-muted-foreground: #a3e0ff;
--info-border: #0f5a6b;
/* Matrix: max-contrast ramp on pure black. */
--mx-call-conf: #2ee87f;
--mx-call-work: #17a85a;
--mx-dx-conf: #7d97ff;
--mx-dx-work: #3646b0;
--mx-none: #3a3a3a;
--scrollbar-thumb: #4a4a4a;
--scrollbar-thumb-hover: #666666;
--card-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
color-scheme: dark;
}
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
the generated utilities reference var(--) directly, so overriding a var in
a [data-theme] block re-skins every utility at runtime. */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive-muted: var(--destructive-muted);
--color-destructive-muted-foreground: var(--destructive-muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-muted: var(--success-muted);
--color-success-muted-foreground: var(--success-muted-foreground);
--color-success-border: var(--success-border);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-muted: var(--warning-muted);
--color-warning-muted-foreground: var(--warning-muted-foreground);
--color-warning-border: var(--warning-border);
--color-caution: var(--caution);
--color-caution-foreground: var(--caution-foreground);
--color-caution-muted: var(--caution-muted);
--color-caution-muted-foreground: var(--caution-muted-foreground);
--color-caution-border: var(--caution-border);
--color-danger: var(--danger);
--color-danger-foreground: var(--danger-foreground);
--color-danger-muted: var(--danger-muted);
--color-danger-muted-foreground: var(--danger-muted-foreground);
--color-danger-border: var(--danger-border);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-info-muted: var(--info-muted);
--color-info-muted-foreground: var(--info-muted-foreground);
--color-info-border: var(--info-border);
--color-mx-call-conf: var(--mx-call-conf);
--color-mx-call-work: var(--mx-call-work);
--color-mx-dx-conf: var(--mx-dx-conf);
--color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none);
--radius: 0.5rem; --radius: 0.5rem;
@@ -65,18 +380,17 @@
} }
/* Subtle elevation on every Card-styled surface so cards visibly sit on /* Subtle elevation on every Card-styled surface so cards visibly sit on
top of the taupe background paper-on-paper feel. */ top of the background paper-on-paper feel, tuned per theme. */
.bg-card { .bg-card {
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05), box-shadow: var(--card-shadow);
0 0 0 1px rgba(28, 25, 23, 0.02);
} }
/* Warm scrollbar tuned to the deeper bg */ /* Scrollbar tuned to each theme's surface */
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #b8a880; background: var(--scrollbar-thumb);
border-radius: 5px; border-radius: 5px;
border: 2px solid var(--color-background); border: 2px solid var(--background);
} }
::-webkit-scrollbar-thumb:hover { background: #968455; } ::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
+62 -21
View File
@@ -16,6 +16,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"hamlog/internal/applog" "hamlog/internal/applog"
@@ -56,6 +57,11 @@ type Client struct {
mu sync.Mutex // guards conn + writes mu sync.Mutex // guards conn + writes
conn net.Conn conn net.Conn
// Auth handshake state (touched only on the runLoop/readLoop goroutine).
awaitingAuth bool
authTries int
ready atomic.Bool // init commands sent → keepalive may run
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
antennas map[int]string // index → name (rebuilt into status.Antennas) antennas map[int]string // index → name (rebuilt into status.Antennas)
@@ -152,25 +158,11 @@ func (c *Client) runLoop() {
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host }) c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr()) applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr())
// When reached remotely the device announces "V… AG AUTH" and refuses every // Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a
// command (R1|FF) until the client logs in. Sent FIRST so it's authenticated // password we send "auth code=<pw>" and only send the subscribe/get commands
// before the subscribe/get commands (the device processes the stream in // once it's accepted (retrying on FF); without AUTH they go out immediately.
// order). On the LAN there's no AUTH and the password is blank → skipped. c.awaitingAuth, c.authTries = false, 0
if c.password != "" { c.ready.Store(false)
if err := c.send("login " + c.password); err != nil {
applog.Printf("antgenius: login send failed: %v", err)
} else {
applog.Printf("antgenius: sent login (password %d chars)", len(c.password))
}
}
// Subscribe to live updates and pull the initial state. Command set and
// order mirror a known-working Node-RED v4 client (WA9WUD).
for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} {
if err := c.send(cmd); err != nil {
applog.Printf("antgenius: init send %q failed: %v", cmd, err)
}
}
done := make(chan struct{}) done := make(chan struct{})
go c.keepalive(conn, done) go c.keepalive(conn, done)
@@ -207,12 +199,27 @@ func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
case <-c.stop: case <-c.stop:
return return
case <-t.C: case <-t.C:
if !c.ready.Load() {
continue // not authenticated / subscribed yet
}
_ = c.send("port get 1") _ = c.send("port get 1")
_ = c.send("port get 2") _ = c.send("port get 2")
} }
} }
} }
// sendInit subscribes to live updates and pulls the initial state. Called once
// the link is ready (authenticated, or immediately when no AUTH is required).
// Command set/order mirror a known-working Node-RED v4 client (WA9WUD).
func (c *Client) sendInit() {
c.ready.Store(true)
for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} {
if err := c.send(cmd); err != nil {
applog.Printf("antgenius: init send %q failed: %v", cmd, err)
}
}
}
func (c *Client) readLoop(conn net.Conn) error { func (c *Client) readLoop(conn net.Conn) error {
r := bufio.NewReader(conn) r := bufio.NewReader(conn)
var sb strings.Builder var sb strings.Builder
@@ -267,16 +274,31 @@ func (c *Client) handleLine(line string) {
if line == "" { if line == "" {
return return
} }
// Banner like "V4.1.16 AG" — just confirms the link is up. // Banner: "V4.1.16 AG" (LAN) or "V4.1.16 AG AUTH" (remote). It drives what we
// send next — authenticate first when AUTH is announced, else go straight to
// the subscribe/get commands.
if line[0] == 'V' && strings.Contains(line, "AG") { if line[0] == 'V' && strings.Contains(line, "AG") {
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" }) c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
if strings.Contains(line, "AUTH") && c.password != "" {
c.awaitingAuth, c.authTries = true, 1
applog.Printf("antgenius: AUTH required — authenticating")
_ = c.send("auth code=" + c.password)
} else {
if strings.Contains(line, "AUTH") {
applog.Printf("antgenius: device requires AUTH but no remote password set (Settings → Antenna Genius)")
}
c.sendInit()
}
return return
} }
// R<seq>|<hex>|<message> or S<seq>|<message> // R<seq>|<hex>|<message> or S<seq>|<message>
var msg string var hexCode, msg string
switch { switch {
case strings.HasPrefix(line, "R"): case strings.HasPrefix(line, "R"):
p := strings.SplitN(line, "|", 3) p := strings.SplitN(line, "|", 3)
if len(p) >= 2 {
hexCode = p[1]
}
if len(p) == 3 { if len(p) == 3 {
msg = p[2] msg = p[2]
} }
@@ -287,6 +309,25 @@ func (c *Client) handleLine(line string) {
} }
} }
msg = strings.TrimSpace(msg) msg = strings.TrimSpace(msg)
// Auth result: the reply to "auth code=" carries an empty message; hex "0" =
// accepted. The device rejects the FIRST attempt (R|FF) and accepts a retry,
// so resend a few times before giving up.
if c.awaitingAuth && strings.HasPrefix(line, "R") && msg == "" {
switch {
case hexCode == "0":
c.awaitingAuth = false
applog.Printf("antgenius: authenticated")
c.sendInit()
case c.authTries < 4:
c.authTries++
applog.Printf("antgenius: auth rejected (R|%s|) — retry %d", hexCode, c.authTries)
_ = c.send("auth code=" + c.password)
default:
c.awaitingAuth = false
applog.Printf("antgenius: auth failed after %d tries (R|%s|) — check the remote password", c.authTries, hexCode)
}
return
}
switch { switch {
case strings.HasPrefix(msg, "antenna "): case strings.HasPrefix(msg, "antenna "):
c.parseAntenna(msg) c.parseAntenna(msg)