10 Commits
59 changed files with 2595 additions and 765 deletions
+142 -16
View File
@@ -9,6 +9,7 @@ import (
"io" "io"
"math" "math"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime/debug" "runtime/debug"
"sort" "sort"
@@ -153,6 +154,7 @@ const (
// port is fixed at 9007, so only the IP is configurable. // port is fixed at 9007, so only the IP is configurable.
keyAntGeniusEnabled = "antgenius.enabled" keyAntGeniusEnabled = "antgenius.enabled"
keyAntGeniusHost = "antgenius.host" keyAntGeniusHost = "antgenius.host"
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius. // PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
keyPGXLEnabled = "pgxl.enabled" keyPGXLEnabled = "pgxl.enabled"
@@ -176,7 +178,7 @@ const (
keyWKUsePTT = "winkeyer.use_ptt" keyWKUsePTT = "winkeyer.use_ptt"
keyWKSerialEcho = "winkeyer.serial_echo" keyWKSerialEcho = "winkeyer.serial_echo"
keyWKMacros = "winkeyer.macros" // JSON array of {label,text} keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
keyWKEngine = "winkeyer.engine" // "winkeyer" | "tci" keyWKEngine = "winkeyer.engine" // "winkeyer" | "icom" | "tci"
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
@@ -459,6 +461,12 @@ type App struct {
opLat float64 opLat float64
opLon float64 opLon float64
opSet bool opSet bool
opCall string // active profile callsign, cached for outbound UDP emitters
// Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only
// send when the frequency or mode actually changes.
udpLastFreqHz int64
udpLastMode string
} }
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the // gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
@@ -534,6 +542,7 @@ func (a *App) refreshOperatorGrid() {
if err != nil { if err != nil {
return return
} }
a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign))
lat, lon, ok := gridToLatLon(p.MyGrid) lat, lon, ok := gridToLatLon(p.MyGrid)
if !ok { if !ok {
return return
@@ -543,6 +552,36 @@ func (a *App) refreshOperatorGrid() {
a.opSet = true a.opSet = true
} }
// emitRadioUDP forwards the rig's frequency/mode to any enabled outbound UDP
// emitters (PstRotator, N1MM RadioInfo). Deduped so it only fires when the
// operating frequency or mode actually changes. The send runs off the CAT
// goroutine so a slow/failed datagram can't stall polling.
func (a *App) emitRadioUDP(s cat.RigState) {
if a.udp == nil {
return
}
rx := s.FreqHz
if s.RxFreqHz != 0 { // split: RxFreqHz is the active/listening VFO
rx = s.RxFreqHz
}
if rx <= 0 {
return
}
if rx == a.udpLastFreqHz && s.Mode == a.udpLastMode {
return
}
a.udpLastFreqHz = rx
a.udpLastMode = s.Mode
rs := udp.RadioState{
StationName: a.opCall,
OpCall: a.opCall,
RxFreqHz: rx,
TxFreqHz: s.FreqHz,
Mode: s.Mode,
}
go a.udp.EmitRadioState(rs)
}
// dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface // dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface
// without making the lookup package import dxcc. // without making the lookup package import dxcc.
type dxccAdapter struct{ m *dxcc.Manager } type dxccAdapter struct{ m *dxcc.Manager }
@@ -582,17 +621,15 @@ func (a *App) startup(ctx context.Context) {
// Windows reinstall. It lives OUTSIDE the DB since we must know the path // Windows reinstall. It lives OUTSIDE the DB since we must know the path
// before opening it. // before opening it.
if custom := readDBPointer(dataDir); custom != "" { if custom := readDBPointer(dataDir); custom != "" {
// Portability guard: a pointer that is merely ANOTHER folder's default DB // Honour any chosen database that still exists on disk. Only fall back to
// location ("…/<other>/data/opslog.db") means the portable folder was // this folder's own default when the pointed-to file is genuinely GONE —
// renamed or copied — its config.json still points at the original. Ignore // the portable folder was copied to a machine without the original drive,
// it and use THIS folder's own data (and clear the stale pointer so it // or the path was deleted — and clear the now-dangling pointer so it stops
// stops happening). A genuine custom location — another drive, a different // trying. (The old heuristic guessed "folder moved" from the file name and
// filename — is NOT default-style, so it's still honoured. // wrongly discarded valid logbooks named …/data/opslog.db, sending the user
stale := strings.EqualFold(filepath.Base(custom), "opslog.db") && // back to the default every launch — the save appeared not to stick.)
strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") && if _, err := os.Stat(custom); err != nil {
!strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir)) fmt.Printf("OpsLog: chosen database %q not found (%v) — using %s\n", custom, err, a.dbPath)
if stale {
fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath)
_ = writeDBPointer(dataDir, "") _ = writeDBPointer(dataDir, "")
} else { } else {
a.dbPath = custom a.dbPath = custom
@@ -726,11 +763,13 @@ func (a *App) startup(ctx context.Context) {
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d) fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
} }
}() }()
// CAT manager: emit pushes state to the frontend via Wails events. // CAT manager: emit pushes state to the frontend via Wails events, and
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
a.cat = cat.NewManager(func(s cat.RigState) { a.cat = cat.NewManager(func(s cat.RigState) {
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cat:state", s) wruntime.EventsEmit(a.ctx, "cat:state", s)
} }
a.emitRadioUDP(s)
}) })
a.reloadCAT() a.reloadCAT()
@@ -1487,6 +1526,26 @@ func (a *App) QuitApp() {
} }
} }
// RestartApp relaunches OpsLog and closes this instance, so a database change
// (the DB pointer is read once at startup) applies without the user manually
// reopening the app. The new process opens the SQLite files while this one is
// closing; SQLite's busy_timeout(5000) covers that brief overlap.
func (a *App) RestartApp() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate executable: %w", err)
}
cmd := exec.Command(exe)
cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err)
}
if a.ctx != nil {
wruntime.Quit(a.ctx)
}
return nil
}
// reloadLookupProviders rebuilds the lookup chain from current settings. // reloadLookupProviders rebuilds the lookup chain from current settings.
// Called at startup and after the user saves new credentials. // Called at startup and after the user saves new credentials.
// //
@@ -1583,6 +1642,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
a.extsvc.OnQSOLogged(id) a.extsvc.OnQSOLogged(id)
} }
a.maybeAutoSendEQSL(q) a.maybeAutoSendEQSL(q)
// Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows.
if a.udp != nil {
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
}
} }
return id, err return id, err
} }
@@ -7842,6 +7905,66 @@ func (a *App) IcomSetScopeMode(fixed bool) error {
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) }) return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
} }
// IcomSetRIT sets the RIT/ΔTX offset in signed Hz.
func (a *App) IcomSetRIT(hz int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRIT(hz) })
}
// IcomSetRITOn toggles RIT.
func (a *App) IcomSetRITOn(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRITOn(on) })
}
// IcomSetXITOn toggles ΔTX (XIT).
func (a *App) IcomSetXITOn(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) })
}
// IcomSendCW keys a CW message through the rig's internal keyer (CI-V 0x17).
func (a *App) IcomSendCW(text string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
err := a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SendCW(text) })
if err != nil {
applog.Printf("icom cw: IcomSendCW(%q) failed: %v", text, err)
}
return err
}
// IcomStopCW aborts the CW message currently being sent.
func (a *App) IcomStopCW() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.StopCW() })
}
// IcomSetKeySpeed sets the CW keyer speed in WPM.
func (a *App) IcomSetKeySpeed(wpm int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetKeySpeed(wpm) })
}
// IcomSetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL).
func (a *App) IcomSetBreakIn(mode int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetBreakIn(mode) })
}
func (a *App) IcomSetPTT(on bool) error { func (a *App) IcomSetPTT(on bool) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
@@ -8900,6 +9023,7 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error {
type AntGeniusSettings struct { type AntGeniusSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Host string `json:"host"` Host string `json:"host"`
Password string `json:"password"` // remote-access password; leave blank on LAN (no AUTH)
} }
// GetAntGeniusSettings returns the persisted Antenna Genius config. // GetAntGeniusSettings returns the persisted Antenna Genius config.
@@ -8908,12 +9032,13 @@ func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) {
if a.settings == nil { if a.settings == nil {
return out, fmt.Errorf("db not initialized") return out, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost) m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost, keyAntGeniusPassword)
if err != nil { if err != nil {
return out, err return out, err
} }
out.Enabled = m[keyAntGeniusEnabled] == "1" out.Enabled = m[keyAntGeniusEnabled] == "1"
out.Host = m[keyAntGeniusHost] out.Host = m[keyAntGeniusHost]
out.Password = m[keyAntGeniusPassword]
return out, nil return out, nil
} }
@@ -8925,6 +9050,7 @@ func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error {
for k, v := range map[string]string{ for k, v := range map[string]string{
keyAntGeniusEnabled: boolStr(s.Enabled), keyAntGeniusEnabled: boolStr(s.Enabled),
keyAntGeniusHost: strings.TrimSpace(s.Host), keyAntGeniusHost: strings.TrimSpace(s.Host),
keyAntGeniusPassword: s.Password,
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -8946,7 +9072,7 @@ func (a *App) startAntGenius() {
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return return
} }
a.antgenius = antgenius.New(s.Host, 9007) a.antgenius = antgenius.New(s.Host, 9007, s.Password)
_ = a.antgenius.Start() _ = a.antgenius.Start()
} }
@@ -9068,7 +9194,7 @@ type WKMacro struct {
type WinkeyerSettings struct { type WinkeyerSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
winkeyer.Config winkeyer.Config
Engine string `json:"engine"` // keyer backend: "winkeyer" | "tci" Engine string `json:"engine"` // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci"
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
SendOnType bool `json:"send_on_type"` // key chars live as typed SendOnType bool `json:"send_on_type"` // key chars live as typed
Macros []WKMacro `json:"macros"` Macros []WKMacro `json:"macros"`
+252
View File
@@ -0,0 +1,252 @@
// Command icomnettest is an iteration probe for the Icom IP remote protocol
// (the LAN server built into the IC-7610 — the one RS-BA1 and wfview talk to).
// We're reimplementing it from the public protocol description, so this tool
// drives the CONTROL stream (default UDP 50001) and hex-dumps every packet both
// ways, letting us confirm the framing / type codes against the real rig before
// folding it into internal/cat/icomnet. Nothing here is copied from wfview
// (GPLv3) — it's a clean-room implementation from the protocol structure.
//
// This first milestone is the CONNECTION HANDSHAKE only (no login yet):
// areYouThere → iAmHere → areYouReady → iAmReady → periodic idle pings.
// Watch the log: if the rig answers our areYouThere we've got the framing right;
// its reply reveals the remote station ID we echo back. Login (token + user/
// password) is the next step once the handshake is confirmed.
//
// Usage:
// go run ./cmd/icomnettest 192.168.1.60 # control port 50001
// go run ./cmd/icomnettest 192.168.1.60 50001 20 # port + run seconds
//
// SAFE: only the control stream, no CI-V commands, no TX — it just opens and
// pings, then disconnects. Share the log and we iterate.
package main
import (
"encoding/binary"
"encoding/hex"
"fmt"
"net"
"os"
"strconv"
"time"
)
// Control-stream packet types (best-known values from the public protocol
// description — the very thing we're verifying with this probe).
const (
typeAreYouThere = 0x03
typeIAmHere = 0x04
typeDisconnect = 0x05
typeAreYouReady = 0x06 // same type both directions (areYouReady / iAmReady)
typeIdle = 0x00 // 16-byte keepalive (retransmit/ack carrier)
typePing = 0x07 // 21-byte ping (offset 16 = 0x00 request / 0x01 reply, +4-byte payload)
)
// ctrlPacket is the 16-byte common control packet, all fields little-endian:
//
// uint32 len (=0x10) · uint16 type · uint16 seq · uint32 sentid · uint32 rcvdid
func ctrlPacket(typ uint16, seq uint16, sentid, rcvdid uint32) []byte {
b := make([]byte, 16)
binary.LittleEndian.PutUint32(b[0:], 0x10)
binary.LittleEndian.PutUint16(b[4:], typ)
binary.LittleEndian.PutUint16(b[6:], seq)
binary.LittleEndian.PutUint32(b[8:], sentid)
binary.LittleEndian.PutUint32(b[12:], rcvdid)
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) {
if len(b) < 16 {
return 0, 0, 0, 0, 0, false
}
length = binary.LittleEndian.Uint32(b[0:])
typ = binary.LittleEndian.Uint16(b[4:])
seq = binary.LittleEndian.Uint16(b[6:])
sentid = binary.LittleEndian.Uint32(b[8:])
rcvdid = binary.LittleEndian.Uint32(b[12:])
return length, typ, seq, sentid, rcvdid, true
}
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: icomnettest <rig-ip> [user] [password]")
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)
}
ip := os.Args[1]
port := 50001
runSecs := 25
user, pass := "", ""
if len(os.Args) >= 4 {
user, pass = os.Args[2], os.Args[3]
}
target := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.Dial("udp4", target)
if err != nil {
fmt.Printf("dial %s: %v\n", target, err)
os.Exit(1)
}
defer conn.Close()
// Our local station ID. Real clients derive it from the local IP:port; a
// stable non-zero value is fine for probing. We'll refine once we see how the
// rig echoes it back.
local := conn.LocalAddr().(*net.UDPAddr)
myID := uint32(local.IP.To4()[0])<<24 | uint32(local.IP.To4()[1])<<16 | uint32(uint16(local.Port))
var remoteID uint32
var seq uint16
logTx := func(name string, p []byte) {
fmt.Printf("TX %-14s (%d bytes)\n%s\n", name, len(p), hex.Dump(p))
if _, err := conn.Write(p); err != nil {
fmt.Printf(" write error: %v\n", err)
}
}
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.
seq++
logTx("areYouThere", ctrlPacket(typeAreYouThere, seq, myID, 0))
// Read loop: dump everything, and advance the handshake when we recognise a
// reply. Runs for runSecs then disconnects.
deadline := time.Now().Add(time.Duration(runSecs) * time.Second)
buf := make([]byte, 2048)
lastIdle := time.Now()
readyStarted := false
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
n, err := conn.Read(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// Periodic idle keepalive once connected.
if remoteID != 0 && time.Since(lastIdle) > 100*time.Millisecond {
seq++
logTx("idle", ctrlPacket(typeIdle, seq, myID, remoteID))
lastIdle = time.Now()
}
continue
}
fmt.Printf("read error: %v\n", err)
break
}
pkt := append([]byte(nil), buf[:n]...)
length, typ, rseq, sentid, rcvdid, ok := parseHeader(pkt)
if !ok {
fmt.Printf("RX (%d bytes, too short to parse)\n%s\n", n, hex.Dump(pkt))
continue
}
fmt.Printf("RX len=%d type=0x%02X seq=%d sentid=0x%08X rcvdid=0x%08X (%d bytes)\n%s\n",
length, typ, rseq, sentid, rcvdid, n, hex.Dump(pkt))
switch typ {
case typeIAmHere:
remoteID = sentid // the rig's ID — echo it back as rcvdid from now on
fmt.Printf(">> iAmHere: remoteID=0x%08X — sending areYouReady\n\n", remoteID)
seq++
logTx("areYouReady", ctrlPacket(typeAreYouReady, seq, myID, remoteID))
readyStarted = true
case typeAreYouReady:
if readyStarted && !loginSent {
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:
// Reply to the rig's ping: mirror the packet, swap sender/receiver IDs,
// set the reply flag at offset 16. Keeps the link healthy so we can
// observe the connection long enough to work on login.
reply := append([]byte(nil), pkt...)
if len(reply) >= 17 {
binary.LittleEndian.PutUint32(reply[8:], myID)
binary.LittleEndian.PutUint32(reply[12:], remoteID)
reply[16] = 0x01 // request → reply
logTx("pingReply", reply)
}
case typeDisconnect:
fmt.Printf(">> rig sent disconnect\n\n")
}
}
// Clean disconnect.
if remoteID != 0 {
seq++
logTx("disconnect", ctrlPacket(typeDisconnect, seq, myID, remoteID))
}
fmt.Println("Done. Paste the log — especially the rig's replies to areYouThere.")
}
+92
View File
@@ -0,0 +1,92 @@
// Command psttest probes PstRotatorAz's UDP tracking input to discover the wire
// format it expects for the "DXLog.net" tracker. We don't have the spec, so this
// sends a series of labelled candidate datagrams to the target and pauses between
// each so you can watch PstRotatorAz and note which one it reacts to (the band
// filter "Rotate Antenna only for the Selected Bands" changes, or the frequency
// shows in the tracker box).
//
// Setup on the PstRotatorAz side:
// - Tracker = DXLog.net
// - Note the UDP port it listens on (commonly 12040) and this PC's IP
// - In the tracker setup tick a couple of bands so the band filter is visible
//
// Usage:
//
// go run ./cmd/psttest 192.168.1.50:12040 # default 14025.5 kHz
// go run ./cmd/psttest 192.168.1.50:12040 21074 # a specific kHz
// go run ./cmd/psttest 192.168.1.50:12040 21074 3 # repeat each variant 3x
//
// Watch PstRotatorAz as it runs; when one variant makes it react, that's the
// format — tell me the variant number and I'll wire it into OpsLog.
package main
import (
"fmt"
"net"
"os"
"strconv"
"time"
)
type variant struct {
name string
// build returns the datagram for a given frequency in kHz.
build func(khz float64) string
}
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: psttest <ip:port> [freqKHz] [repeat]")
fmt.Println("example: psttest 192.168.1.50:12040 3525.5")
os.Exit(2)
}
target := os.Args[1]
khz := 3525.5
if len(os.Args) >= 3 {
if v, err := strconv.ParseFloat(os.Args[2], 64); err == nil {
khz = v
}
}
repeat := 1
if len(os.Args) >= 4 {
if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 {
repeat = v
}
}
hz := int64(khz*1000 + 0.5)
tensHz := hz / 10 // N1MM/DXLog <Freq> unit
// Tag <PST><FREQUENCY> is confirmed. PstRotator strips the decimal point and
// reads the digits, so pin down the exact unit: watch which one shows the real
// frequency (3.5255 MHz), not 0.35255 / 0.003525 / 0.035255.
variants := []variant{
{"FREQUENCY = Hz integer (expect 3.5255 MHz) ← prediction", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", hz) }},
{"FREQUENCY = tens-of-Hz (expect 0.35255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensHz) }},
{"FREQUENCY = kHz integer (expect 0.003525 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", int64(k+0.5)) }},
{"FREQUENCY = kHz decimal (baseline: gave 0.035255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%.1f</FREQUENCY></PST>", k) }},
}
conn, err := net.Dial("udp", target)
if err != nil {
fmt.Printf("dial %s: %v\n", target, err)
os.Exit(1)
}
defer conn.Close()
fmt.Printf("Sending %d variants to %s at %.1f kHz (%d Hz, %d tens-of-Hz), %dx each.\n\n", len(variants), target, khz, hz, tensHz, repeat)
for i, v := range variants {
msg := v.build(khz)
fmt.Printf("── Variant %d: %s\n", i+1, v.name)
fmt.Printf(" payload: %s\n", msg)
for r := 0; r < repeat; r++ {
if _, err := conn.Write([]byte(msg)); err != nil {
fmt.Printf(" write error: %v\n", err)
}
time.Sleep(300 * time.Millisecond)
}
fmt.Printf(" → sent. Watch PstRotatorAz for ~4s…\n\n")
time.Sleep(4 * time.Second)
}
fmt.Println("Done. Which variant number made PstRotatorAz react?")
}
+195 -102
View File
@@ -31,6 +31,7 @@ import {
ListCountries, ListCountries,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
@@ -323,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" />
@@ -590,7 +591,7 @@ export default function App() {
// Contest session: load once, then persist on every change (merge + save). // Contest session: load once, then persist on every change (merge + save).
useEffect(() => { useEffect(() => {
GetUIPref('opslog.contest').then((s) => { GetUIPref('opslog.contest').then((s) => {
if (s) { try { setContest({ ...CONTEST_DEFAULT, ...JSON.parse(s) }); } catch {} } if (s) { try { const c = { ...CONTEST_DEFAULT, ...JSON.parse(s) }; setContest(c); if (c.active) setContestTabEnabled(true); } catch {} }
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
const updateContest = (patch: Partial<ContestSession>) => { const updateContest = (patch: Partial<ContestSession>) => {
@@ -663,6 +664,24 @@ export default function App() {
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
// CW keyer output engine (persisted in the WinKeyer settings, chosen in
// Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
const cwSourceRef = useRef(cwSource);
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
// actually transmit. Exposed in the CW keyer panel when the engine is Icom.
const [icomBreakIn, setIcomBreakIn] = useState(0);
const setBreakIn = useCallback((m: number) => { setIcomBreakIn(m); IcomSetBreakIn(m).catch(() => {}); }, []);
// Seed the current break-in from the rig when the CW panel becomes active in
// Icom mode (so the control reflects the radio's real state).
useEffect(() => {
if (cwSource !== 'icom' || !wkEnabled || !(catState.backend === 'icom' && catState.connected)) return;
GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {});
}, [cwSource, wkEnabled, catState.backend, catState.connected]);
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds) // Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
// until a reply is entered or it's stopped. Persisted as UI prefs. // until a reply is entered or it's stopped. Persisted as UI prefs.
const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1'); const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
@@ -678,7 +697,10 @@ export default function App() {
const wkEscClearsRef = useRef(true); const wkEscClearsRef = useRef(true);
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]); useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]); useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
// === Digital Voice Keyer (DVK) === // === Digital Voice Keyer (DVK) ===
@@ -774,6 +796,9 @@ export default function App() {
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster). // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1'); const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]); useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
// Contest tab is hidden until enabled from Tools → Contest mode.
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
const [dvkEnabled, setDvkEnabled] = useState(false); const [dvkEnabled, setDvkEnabled] = useState(false);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]); const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
@@ -836,6 +861,12 @@ export default function App() {
return next; return next;
}); });
}, []); }, []);
// Global band-map-tab options (apply to every map in the tab): hide FTx/digital
// spots, and fit each map to its full band span.
const [bandMapHideFt, setBandMapHideFt] = useState(() => localStorage.getItem('opslog.bandMapHideFt') === '1');
const [bandMapFit, setBandMapFit] = useState(() => localStorage.getItem('opslog.bandMapFit') === '1');
const toggleBandMapHideFt = useCallback(() => setBandMapHideFt((v) => { const n = !v; writeUiPref('opslog.bandMapHideFt', n ? '1' : '0'); return n; }), []);
const toggleBandMapFit = useCallback(() => setBandMapFit((v) => { const n = !v; writeUiPref('opslog.bandMapFit', n ? '1' : '0'); return n; }), []);
// Single band map docked beside the table (toggled by the toolbar button, // Single band map docked beside the table (toggled by the toolbar button,
// visible across tabs). Independent of the multi-band "Band Map" tab. // visible across tabs). Independent of the multi-band "Band Map" tab.
const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1'); const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
@@ -1641,6 +1672,7 @@ export default function App() {
setWkMacros((s.macros ?? []) as WKMacro[]); setWkMacros((s.macros ?? []) as WKMacro[]);
setWkEscClears(s.esc_clears_call !== false); setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type); setWkSendOnType(!!s.send_on_type);
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
} catch { /* keyer not configured */ } } catch { /* keyer not configured */ }
}, []); }, []);
@@ -1719,6 +1751,15 @@ export default function App() {
setWkSent(''); setWkSent('');
const resolved = resolveCW(rawText); const resolved = resolveCW(rawText);
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "") const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom') {
// The rig's keyer gives no busy echo back, so show the text we sent and,
// for <LOGQSO>, wait the estimated send duration before logging.
setWkSent(resolved);
await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e)));
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
return;
}
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e))); await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has // <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — so the QSO isn't logged (and the form cleared) while CW // finished sending — so the QSO isn't logged (and the form cleared) while CW
@@ -1728,7 +1769,6 @@ export default function App() {
// send duration (text length × WPM) plus slack: log when busy clears OR the // send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first. // estimate elapses, whichever comes first.
if (!doLog) return; if (!doLog) return;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
const deadline = Date.now() + capMs; const deadline = Date.now() + capMs;
@@ -1747,14 +1787,19 @@ export default function App() {
const m = wkMacros[i]; const m = wkMacros[i];
if (!m) break; if (!m) break;
await wkSend(m.text); await wkSend(m.text);
// Wait for the message to finish before the gap+resend. Cap the wait at the // Wait for the message to finish before the gap+resend. The Icom keyer has
// ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP // no busy echo, so just wait the estimated send time. For the WinKeyer, cap
// link the keyer's "busy" status lags badly and stays stuck true for tens of // the wait at the ESTIMATED send time (not the busy flag alone): over a
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead. // remote/serial-over-IP link the "busy" status lags badly and stays stuck
// true for tens of seconds, which made the next CQ fire ~120s late.
if (cwSourceRef.current === 'icom') {
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
} else {
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500; const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs; const deadline = Date.now() + capMs;
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50); while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
}
if (gen !== autoCallGenRef.current) break; if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
} }
@@ -2325,6 +2370,7 @@ export default function App() {
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' }, { type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' }, { type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' }, { type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 }, { type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
@@ -2337,7 +2383,7 @@ export default function App() {
{ name: 'help', label: t('menu.help'), items: [ { name: 'help', label: t('menu.help'), items: [
{ type: 'item', label: t('help.about'), action: 'help.about' }, { type: 'item', label: t('help.about'), action: 'help.about' },
]}, ]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, t]); ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]);
function handleMenu(action: string) { function handleMenu(action: string) {
switch (action) { switch (action) {
@@ -2357,6 +2403,7 @@ export default function App() {
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break; case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break; case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
case 'tools.alerts': setAlertsOpen(true); break; case 'tools.alerts': setAlertsOpen(true); break;
case 'tools.duplicates': setShowDuplicates(true); break; case 'tools.duplicates': setShowDuplicates(true); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
@@ -2470,19 +2517,42 @@ export default function App() {
<div className="flex flex-col w-44"> <div className="flex flex-col w-44">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}> <Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')} {t('field.callsign')}
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>} {lookupBusy && (
{!lookupBusy && lookupResult && ( <span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline"> <Loader2 className="size-2.5 animate-spin" />
<CheckCircle2 className="size-2.5 mr-1" />{lookupResult.source} </span>
</Badge>
)} )}
{!lookupBusy && lookupResult && (() => {
const src = (lookupResult.source || '').toLowerCase();
const tone = src.includes('cache') ? 'amber' : src.includes('qrz') ? 'emerald' : src.includes('ham') ? 'sky' : 'slate';
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-warning', emerald: 'bg-success', sky: 'bg-info', slate: 'bg-muted-foreground' }[tone];
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('size-1.5 rounded-full', dot)} />{lookupResult.source}
</span>
);
})()}
{!lookupBusy && !lookupResult && lookupError && ( {!lookupBusy && !lookupResult && lookupError && (
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge> <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() && (
<button
type="button"
title={t('qrz.openTitle', { call: callsign.trim().toUpperCase() })}
onClick={() => {
const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
}}
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
>
QRZ
</button>
)} )}
</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>
)} )}
@@ -2492,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={() => {
@@ -2538,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) : ''}
@@ -2556,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}
@@ -2575,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}
@@ -2612,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>
@@ -2694,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) : '')}
@@ -2702,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>
); );
@@ -2854,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}
@@ -2887,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})
@@ -2898,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);
@@ -2921,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 (
@@ -3051,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" />
@@ -3084,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>
@@ -3096,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' && (
@@ -3127,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}
@@ -3139,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" />
@@ -3151,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)}°` : '—'}
@@ -3163,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>
@@ -3173,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>
@@ -3198,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"
@@ -3212,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"
@@ -3230,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"
@@ -3244,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',
)} )}
> >
@@ -3257,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 && (
@@ -3271,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>
)} )}
@@ -3299,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>
) : ( ) : (
@@ -3398,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>
@@ -3614,20 +3684,29 @@ export default function App() {
{wkEnabled && ( {wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0"> <div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel <WinkeyerPanel
status={wkStatus} status={cwSource === 'icom'
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
: wkStatus}
ports={wkPorts} ports={wkPorts}
port={wkPort} port={wkPort}
wpm={wkWpm} wpm={wkWpm}
macros={wkMacros} macros={wkMacros}
sent={wkSent} sent={wkSent}
source={cwSource}
breakIn={icomBreakIn}
onSetBreakIn={setBreakIn}
onSelectPort={wkSelectPort} onSelectPort={wkSelectPort}
onRefreshPorts={reloadWkPorts} onRefreshPorts={reloadWkPorts}
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))} onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
onDisconnect={() => WinkeyerDisconnect().catch(() => {})} onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} onSetSpeed={(w) => {
setWkWpm(w); saveWk({ wpm: w });
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
}}
onSend={wkSend} onSend={wkSend}
onSendMacro={wkSendMacro} onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }} onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)} onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType} sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType} onToggleSendOnType={wkToggleSendOnType}
@@ -3667,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'}
@@ -3684,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. */}
@@ -3697,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 })}
> >
@@ -3733,10 +3812,22 @@ export default function App() {
</TabsTrigger> </TabsTrigger>
<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>
<TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-amber-600 data-[state=active]:text-amber-600')}> {contestTabEnabled && (
<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
role="button"
aria-label="Close Contest"
title={t('dup.close')}
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); setContestTabEnabled(false); setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); }}
>
<X className="size-3" />
</span>
</TabsTrigger> </TabsTrigger>
)}
{netEnabled && ( {netEnabled && (
<TabsTrigger value="net" className="gap-1.5"> <TabsTrigger value="net" className="gap-1.5">
{t('tab.net')} {t('tab.net')}
@@ -3754,20 +3845,6 @@ export default function App() {
)} )}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>} {catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>} {catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{/* Not a tab — QRZ blocks embedding, so this opens the call's
QRZ.com page in the system browser. Styled like a trigger. */}
<button
type="button"
disabled={!callsign.trim()}
title={callsign.trim() ? `Open ${callsign.trim().toUpperCase()} on QRZ.com` : 'Enter a callsign first'}
onClick={() => {
const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
}}
className="inline-flex items-center justify-center gap-1 whitespace-nowrap px-3 py-1.5 text-xs font-medium text-muted-foreground border-b-2 border-transparent transition-all hover:text-foreground disabled:pointer-events-none disabled:opacity-50 -mb-px"
>
QRZ.com
</button>
{qslTabOpen && ( {qslTabOpen && (
<TabsTrigger value="qsl" className="gap-1.5"> <TabsTrigger value="qsl" className="gap-1.5">
QSL Manager QSL Manager
@@ -3802,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
@@ -3891,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
@@ -3949,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>
@@ -4062,9 +4139,11 @@ export default function App() {
<AwardsPanel onEditQSO={openEdit} /> <AwardsPanel onEditQSO={openEdit} />
</TabsContent> </TabsContent>
{contestTabEnabled && (
<TabsContent value="contest" className="flex-1 min-h-0 p-0"> <TabsContent value="contest" className="flex-1 min-h-0 p-0">
<ContestPanel session={contest} onChange={updateContest} /> <ContestPanel session={contest} onChange={updateContest} />
</TabsContent> </TabsContent>
)}
{/* FlexRadio SmartSDR-style control panel — only present when the CAT {/* FlexRadio SmartSDR-style control panel — only present when the CAT
backend is a FlexRadio. */} backend is a FlexRadio. */}
@@ -4094,7 +4173,7 @@ export default function App() {
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap"> <div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">Bands:</span> <span className="text-xs text-muted-foreground mr-1">{t('bmp.bandsLabel')}</span>
{bands.map((b) => { {bands.map((b) => {
const on = bandMapBands.includes(b); const on = bandMapBands.includes(b);
return ( return (
@@ -4105,6 +4184,18 @@ export default function App() {
</button> </button>
); );
})} })}
<span className="ml-auto flex items-center gap-1">
<button type="button" onClick={toggleBandMapHideFt} title={t('bmp.hideFtTitle')}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
bandMapHideFt ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('bmp.hideFt')}
</button>
<button type="button" onClick={toggleBandMapFit} title={t('bmp.fitTitle')}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
bandMapFit ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('bmp.fitBand')}
</button>
</span>
</div> </div>
<div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto"> <div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
{bandMapBands.length === 0 ? ( {bandMapBands.length === 0 ? (
@@ -4120,6 +4211,8 @@ export default function App() {
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick} onSpotClick={handleSpotClick}
onClose={() => toggleBandMapBand(b)} onClose={() => toggleBandMapBand(b)}
hideDigital={bandMapHideFt}
fitToBand={bandMapFit}
/> />
</div> </div>
))} ))}
@@ -4161,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>
); );
@@ -4193,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">
+58 -38
View File
@@ -35,6 +35,12 @@ interface Props {
onClose?: () => void; onClose?: () => void;
side?: 'left' | 'right'; side?: 'left' | 'right';
onToggleSide?: () => void; onToggleSide?: () => void;
// hideDigital drops every DATA-class (FT8/FT4/JS8/…) spot so the crowded
// watering holes don't hog the map. fitToBand overrides zoom so the whole
// band edge-to-edge fits the visible height (no scrolling). Both are driven
// globally from the band-map tab toolbar.
hideDigital?: boolean;
fitToBand?: boolean;
} }
const BAND_RANGES: Record<string, [number, number]> = { const BAND_RANGES: Record<string, [number, number]> = {
@@ -55,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.
@@ -97,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',
@@ -147,7 +153,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq). // last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30; const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) { export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const range = BAND_RANGES[band]; const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? []; const segments = SEGMENT_COLORS[band] ?? [];
@@ -167,7 +173,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
const fallback: [number, number] = range ?? [0, 1]; const fallback: [number, number] = range ?? [0, 1];
const [lo, hi] = fallback; const [lo, hi] = fallback;
const span = hi - lo; const span = hi - lo;
const pxPerKHz = PX_PER_KHZ[zoomIdx]; // Fit-to-band computes the exact px/kHz so the whole band edge-to-edge fills
// the visible height; otherwise use the discrete zoom step.
const fitPxPerKHz = fitToBand && containerH > 0 && span > 0
? Math.max(0.05, (containerH - TOP_PAD - BOT_PAD) / span)
: 0;
const pxPerKHz = fitPxPerKHz || PX_PER_KHZ[zoomIdx];
// Anti-overlap layout: each label wants to sit at its true freq, but // Anti-overlap layout: each label wants to sit at its true freq, but
// never closer than PILL_H from the previous one. Sorted top-to-bottom // never closer than PILL_H from the previous one. Sorted top-to-bottom
@@ -199,7 +210,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
// spots carry no mode word and the band-plan fallback labels them the // spots carry no mode word and the band-plan fallback labels them the
// generic "DATA" rather than "FT8". CW and SSB are always shown in full. // generic "DATA" rather than "FT8". CW and SSB are always shown in full.
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA'; const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
const ftSpots = inBand.filter(isFlood); // hideDigital removes them entirely; otherwise they're capped below.
const ftSpots = hideDigital ? [] : inBand.filter(isFlood);
const otherSpots = inBand.filter((s) => !isFlood(s)); const otherSpots = inBand.filter((s) => !isFlood(s));
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties // Rank a DATA spot by usefulness (new entity → unworked → worked); ties
@@ -272,12 +284,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 }); out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
} }
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0; const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
// In fit mode the band scale exactly fills the viewport (innerH = containerH
// pads); when crowded sub-bands stack labels past the bottom the content
// grows and a scroll bar appears — the map defaults to the top so the whole
// band is visible, and you scroll down to reach the stacked spots.
return { return {
placed: out, placed: out,
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD), totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
hidden: hiddenCount, hidden: hiddenCount,
}; };
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]); }, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz, hideDigital]);
// freqToY for elements rendered outside the memo (ticks, rig pointer). // freqToY for elements rendered outside the memo (ticks, rig pointer).
// Must mirror the same offset so the rig triangle sits on the right kHz. // Must mirror the same offset so the rig triangle sits on the right kHz.
@@ -288,15 +304,19 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
// frequency actually changes (or band/zoom/size), so tuning follows the rig // frequency actually changes (or band/zoom/size), so tuning follows the rig
// while a stable frequency leaves your manual scroll alone. // while a stable frequency leaves your manual scroll alone.
useEffect(() => { useEffect(() => {
if (!range || containerH <= 0 || currentFreqHz <= 0) return; if (!range || containerH <= 0) return;
const kHz = currentFreqHz / 1000;
if (kHz < lo || kHz > hi) return;
const el = scrollerRef.current; const el = scrollerRef.current;
if (!el) return; if (!el) return;
// Fit-to-band: keep the view at the top so the full band is always in frame;
// any overflow spots are reached by scrolling down (don't follow the rig).
if (fitToBand) { el.scrollTop = 0; return; }
if (currentFreqHz <= 0) return;
const kHz = currentFreqHz / 1000;
if (kHz < lo || kHz > hi) return;
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2); el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
// freqToY is recomputed each render; intentionally excluded from deps. // freqToY is recomputed each render; intentionally excluded from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz]); }, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
useEffect(() => { useEffect(() => {
const el = scrollerRef.current; const el = scrollerRef.current;
@@ -350,13 +370,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
<div className="h-full w-full flex flex-col min-h-0 bg-card"> <div className="h-full w-full flex flex-col min-h-0 bg-card">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0"> <div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span> <span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0} <button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30" className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomOut')}> title={t('bmp.zoomOut')}>
<Minus className="size-3" /> <Minus className="size-3" />
</button> </button>
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{pxPerKHz}px/kHz</span> <span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1} <button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30" className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}> title={t('bmp.zoomIn')}>
<Plus className="size-3" /> <Plus className="size-3" />
@@ -500,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>
+243 -60
View File
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react'; import { Radio, AudioLines, RefreshCw, Mic, Activity, SlidersHorizontal } from 'lucide-react';
import { import {
GetIcomState, IcomRefresh, GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel, IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter, IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT, IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency, IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -19,6 +20,7 @@ type IcomState = {
af_gain: number; rf_gain: number; af_gain: number; rf_gain: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
agc?: string; preamp: number; att: number; filter: number; agc?: string; preamp: number; att: number; filter: number;
rit_hz: number; rit_on: boolean; xit_on: boolean;
}; };
const ZERO: IcomState = { const ZERO: IcomState = {
@@ -27,6 +29,7 @@ const ZERO: IcomState = {
af_gain: 0, rf_gain: 0, af_gain: 0, rf_gain: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
preamp: 0, att: 0, filter: 1, preamp: 0, att: 0, filter: 1,
rit_hz: 0, rit_on: false, xit_on: false,
}; };
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: { function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
@@ -61,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 }}
/> />
); );
@@ -87,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>
); );
@@ -145,6 +148,58 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
return <div className="flex items-center gap-2">{body}</div>; return <div className="flex items-center gap-2">{body}</div>;
} }
// HdrMeter — a compact live meter for the model header band (S when receiving,
// Po/SWR when transmitting). Clickable variant sends the S reading to RST tx.
function HdrMeter({ label, value, accent, scale, onClick, title }: {
label: string; value: number; accent: string; scale: string; onClick?: () => void; title?: string;
}) {
const v = Math.max(0, Math.min(100, value));
const body = (
<>
<span className="w-5 shrink-0 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<div className="w-24 sm:w-32 h-2 rounded-full bg-muted/70 overflow-hidden">
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
</div>
<span className="w-12 text-right text-[11px] font-mono font-bold tabular-nums" style={{ color: accent }}>{scale}</span>
</>
);
if (onClick) return <button type="button" onClick={onClick} title={title} className="flex items-center gap-1.5 rounded-md hover:bg-muted/60 px-1.5 py-0.5 -my-0.5">{body}</button>;
return <div className="flex items-center gap-1.5 px-1.5">{body}</div>;
}
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
label: string; on: boolean; hz: number; accent: string;
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const cb = useRef(onDelta); cb.current = onDelta;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} />
<div ref={ref} title="Wheel / ± to shift"
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground"></button>
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
{hz > 0 ? '+' : hz < 0 ? '' : ''}{Math.abs(hz)} Hz
</span>
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
</div>
<button type="button" onClick={onClear}
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
</div>
);
}
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the // sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and // CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
// the RST-tx value on click. // the RST-tx value on click.
@@ -157,15 +212,36 @@ function sParts(v: number): { s: number; over: number; label: string } {
return { s, over: 0, label: `S${s}` }; return { s, over: 0, label: `S${s}` };
} }
// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
// (near-black → blue → cyan → green → amber → red).
const WF_STOPS: [number, [number, number, number]][] = [
[0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
[0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
];
function wfColor(v: number): [number, number, number] {
v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
for (let i = 1; i < WF_STOPS.length; i++) {
if (v <= WF_STOPS[i][0]) {
const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
const f = (v - a) / (b - a || 1);
return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
}
}
return WF_STOPS[WF_STOPS.length - 1][1];
}
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the // ScopePanadapter — enables the rig's spectrum-scope stream and draws the
// reassembled sweep on a canvas. The amplitude array is raw rig scale (~0-160); // reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
// we normalise to the tallest recent peak so the trace fills the height. // on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
function ScopePanadapter() { function ScopePanadapter() {
const { t } = useI18n(); const { t } = useI18n();
const [on, setOn] = useState(false); const [on, setOn] = useState(false);
const [fixed, setFixed] = useState(true); const [fixed, setFixed] = useState(true);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
const peakRef = useRef(160); // running amplitude ceiling for auto-scale const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
@@ -198,7 +274,7 @@ function ScopePanadapter() {
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0); vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
} catch {} } catch {}
if (vfo > 0) vfoRef.current = vfo; if (vfo > 0) vfoRef.current = vfo;
draw(sw.amp, sw.low_hz, sw.high_hz, vfo); draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
} }
} catch {} } catch {}
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number; if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
@@ -236,7 +312,7 @@ function ScopePanadapter() {
return () => el.removeEventListener('wheel', onWheel); return () => el.removeEventListener('wheel', onWheel);
}, [on]); }, [on]);
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => { const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
const cv = canvasRef.current; const cv = canvasRef.current;
if (!cv) return; if (!cv) return;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -245,66 +321,133 @@ function ScopePanadapter() {
const ctx = cv.getContext('2d'); const ctx = cv.getContext('2d');
if (!ctx) return; if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump. // Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
const peak = Math.max(...amp); const peak = Math.max(...amp);
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40); peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
const scale = peakRef.current; const scale = peakRef.current;
const n = amp.length;
// Background — deep navy vertical gradient.
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
// Grid. // Grid.
ctx.strokeStyle = 'rgba(120,120,120,0.15)'; ctx.strokeStyle = 'rgba(120,150,200,0.08)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
// Spectrum trace, filled under the line. const xOf = (i: number) => (i / (n - 1)) * w;
const n = amp.length; const yOf = (v: number) => h - Math.min(1, v / scale) * h;
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
const hold = holdRef.current;
if (hold.length !== n) hold.length = n, hold.fill(0);
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
// Filled spectrum area.
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, h); ctx.moveTo(0, h);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
const x = (i / (n - 1)) * w; ctx.lineTo(w, h); ctx.closePath();
const y = h - Math.min(1, amp[i] / scale) * h;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, h); const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(56,189,248,0.55)'); grad.addColorStop(0, 'rgba(56,189,248,0.40)');
grad.addColorStop(1, 'rgba(56,189,248,0.05)'); grad.addColorStop(1, 'rgba(56,189,248,0.02)');
ctx.fillStyle = grad; ctx.fillStyle = grad; ctx.fill();
ctx.fill();
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 1.25;
ctx.stroke();
// VFO marker: a vertical line at the current frequency within the span. // Peak-hold trace (thin, faint).
if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) { ctx.beginPath();
const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w; for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
ctx.strokeStyle = 'rgba(239,68,68,0.95)'; ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.lineWidth = 1.5;
// Live spectrum trace with a soft glow.
ctx.save();
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
ctx.beginPath();
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
ctx.restore();
// VFO marker: translucent band + crisp line + top marker triangle. In centre
// mode the span tracks the VFO, so fall back to the exact centre when the
// reported freq isn't inside the (just-updated) span — you always see where
// you are.
const inSpan = vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz;
const markerX = inSpan ? ((vfoHz - lowHz) / (highHz - lowHz)) * w : (!fixedMode ? w / 2 : -1);
if (markerX >= 0) {
const x = markerX;
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
ctx.fillStyle = 'rgba(244,63,94,0.95)';
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
} }
// Frequency scale (edges + centre), only when the rig reported the span. // Frequency scale. In fixed mode the rig reports usable edge frequencies, so
if (lowHz > 0 && highHz > lowHz) { // we label low/centre/high from them. In centre mode the header frame's edge
// pair isn't a usable low..high range, but the scope is centred on the VFO —
// so we always label the centre with the live VFO frequency (which we fetch
// each sweep), and only add edge labels when the reported edges genuinely
// bracket the VFO. That guarantees you always see your frequency in CTR.
const mhz = (hz: number) => (hz / 1e6).toFixed(3); const mhz = (hz: number) => (hz / 1e6).toFixed(3);
ctx.fillStyle = 'rgba(226,232,240,0.75)';
ctx.font = '10px ui-monospace, monospace'; ctx.font = '10px ui-monospace, monospace';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2); ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2); ctx.fillStyle = 'rgba(226,232,240,0.85)';
ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2); const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
const validEdges = lowHz > 0 && highHz > lowHz;
if (fixedMode) {
if (validEdges) {
label(mhz(lowHz), 4, 'left');
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
label(mhz(highHz), w - 4, 'right');
} }
} else {
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
label(mhz(lowHz), 4, 'left');
label(mhz(highHz), w - 4, 'right');
}
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
}
ctx.shadowBlur = 0;
drawWaterfall(amp, scale);
}; };
// drawWaterfall scrolls the history down one row and paints the newest sweep
// as a colour-mapped line at the top.
const drawWaterfall = (amp: number[], scale: number) => {
const cv = wfRef.current;
if (!cv) return;
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
const ctx = cv.getContext('2d');
if (!ctx) return;
// Scroll everything down by one pixel row.
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
// Paint the new top row.
const row = ctx.createImageData(w, 1);
const n = amp.length;
for (let x = 0; x < w; x++) {
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
const [r, g, b] = wfColor(amp[i] / scale);
const o = x * 4;
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
}
ctx.putImageData(row, 0, 0);
};
// Collapsible card: when the scope is off, only the header band shows (the
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
// and ON/OFF controls live in the header itself.
return ( return (
<Card icon={Activity} title={t('icmp.spectrum')} accent="#38bdf8"> <div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<span className="text-[11px] text-muted-foreground truncate"> <Activity className="size-4" style={{ color: '#38bdf8' }} />
{on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')} <span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
</span> <div className="ml-auto flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0">
{on && ( {on && (
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]} <Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
onChange={(v) => setMode(v === 'FIX')} /> onChange={(v) => setMode(v === 'FIX')} />
@@ -312,11 +455,16 @@ function ScopePanadapter() {
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} /> <Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</div> </div>
</div> </div>
<div className="rounded-lg border border-border bg-black/70 overflow-hidden"> {on && (
<div className="p-3">
<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: 150 }} /> className="w-full block cursor-crosshair" style={{ height: 140 }} />
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
</div>
</div>
)}
</div> </div>
</Card>
); );
} }
@@ -330,6 +478,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [tuning, setTuning] = useState(false); const [tuning, setTuning] = useState(false);
const txRef = useRef(false); const txRef = useRef(false);
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {}); const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
const refresh = async () => { const refresh = async () => {
@@ -364,6 +513,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
window.setTimeout(() => setTuning(false), 4000); window.setTimeout(() => setTuning(false), 4000);
}; };
// RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
const setRit = (hz: number) => {
const v = Math.max(-9999, Math.min(9999, hz));
set({ rit_hz: v }, () => IcomSetRIT(v));
};
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
// clarifier for zero-beating a caller without touching the mouse.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
const s = stRef.current;
if (!s.available || !s.rit_on) return;
e.preventDefault();
setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!st.available) { if (!st.available) {
return ( return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center"> <div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
@@ -381,13 +551,23 @@ 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>
{/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */}
<div className="hidden md:flex items-center">
{tx ? (
<HdrMeter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
) : (() => { const sp = sParts(st.s_meter); return (
<HdrMeter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
); })()}
</div> </div>
<button type="button" onClick={refresh} disabled={busy} <button type="button" onClick={refresh} disabled={busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40"> className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
@@ -399,18 +579,21 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<ScopePanadapter /> <ScopePanadapter />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Live meters. */} {/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
<Card icon={Zap} title={t('icmp.meters')} accent="#f59e0b"> <Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
{tx ? ( <ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
<> onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
{tx && (
<div className="pt-1 border-t border-border/60 space-y-3">
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} /> <Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} /> <Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
</> </div>
) : (() => { const sp = sParts(st.s_meter); return ( )}
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
); })()}
</Card> </Card>
{/* Transmit controls. */} {/* Transmit controls. */}
@@ -426,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>
+187 -96
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check,
} from 'lucide-react'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -26,9 +26,8 @@ import {
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetDataDir,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled, GetTelemetryEnabled, SetTelemetryEnabled,
GetLiveStatusEnabled, SetLiveStatusEnabled, GetLiveStatusEnabled, SetLiveStatusEnabled,
@@ -59,6 +58,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 +333,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 +708,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>
)} )}
@@ -748,7 +810,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' }); const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
// PowerGenius XL (4O3A) amp fan-control settings. // PowerGenius XL (4O3A) amp fan-control settings.
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 }); const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
@@ -925,7 +987,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [mysqlMsg, setMysqlMsg] = useState(''); const [mysqlMsg, setMysqlMsg] = useState('');
const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply" const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null); const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
const [dataDir, setDataDir] = useState('');
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]); const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
@@ -1018,7 +1079,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setDbSettings(await GetDatabaseSettings() as any); } catch {} try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {} try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setDataDir(await GetDataDir()); } catch {}
try { try {
const locs: any = await ListTQSLStationLocations(); const locs: any = await ListTQSLStationLocations();
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
@@ -1474,7 +1534,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 +1637,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 +2203,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}
@@ -2175,6 +2235,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
className="font-mono" className="font-mono"
/> />
</div> </div>
<div className="space-y-1">
<Label>{t('ag2.password')}</Label>
<Input
type="password"
value={antgenius.password ?? ''}
onChange={(e) => setAntgenius((s) => ({ ...s, password: e.target.value }))}
placeholder={t('ag2.passwordPh')}
className="font-mono"
/>
<p className="text-xs text-muted-foreground">{t('ag2.passwordHint')}</p>
</div>
</div> </div>
</> </>
); );
@@ -2267,7 +2338,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}
@@ -2308,6 +2379,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem> <SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem> <SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -2318,6 +2390,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label> </label>
</div> </div>
{wk.engine === 'icom' ? (
<>
<p className="text-xs text-muted-foreground -mt-2">
Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode are configured on the radio; only the speed is set from here (the rig's KEY SPEED).
</p>
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
<span aria-hidden></span>
<span>
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Icom CI-V CW needs the CAT backend set to <strong>Icom</strong> and connected change it under Settings CAT interface, otherwise sending CW will fail.
</span>
</p>
)}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
</>
) : (
<>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Serial port</Label> <Label>Serial port</Label>
@@ -2403,6 +2497,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo <Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
</label> </label>
</div> </div>
</>
)}
{/* Macro editor */} {/* Macro editor */}
<div className="border-t border-border/60 pt-3"> <div className="border-t border-border/60 pt-3">
@@ -2500,16 +2596,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()}
@@ -2519,7 +2615,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>
) : ( ) : (
@@ -2838,8 +2934,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>
@@ -3047,7 +3143,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>
)} )}
@@ -3111,7 +3207,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>
)} )}
@@ -3170,7 +3266,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>
)} )}
@@ -3236,7 +3332,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>
)} )}
@@ -3353,7 +3449,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>
)} )}
@@ -3387,7 +3483,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>
)} )}
@@ -3407,13 +3503,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
function DatabasePanel() { function DatabasePanel() {
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} } async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} }
// The chosen file is persisted (config.json) the moment it's picked; dbMsg
// holds the pending path so the banner can offer a one-click restart to load
// it (the DB pointer is only read at startup).
async function openExisting() { async function openExisting() {
try { try {
const p = await PickOpenDatabase(); const p = await PickOpenDatabase();
if (!p) return; if (!p) return;
await OpenDatabase(p); await OpenDatabase(p);
await refreshDb(); await refreshDb();
setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`); setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function saveCopy() { async function saveCopy() {
@@ -3422,7 +3522,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
if (!p) return; if (!p) return;
await MoveDatabase(p); await MoveDatabase(p);
await refreshDb(); await refreshDb();
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`); setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function createNew() { async function createNew() {
@@ -3431,29 +3531,45 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
if (!p) return; if (!p) return;
await CreateDatabase(p); await CreateDatabase(p);
await refreshDb(); await refreshDb();
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`); setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function resetDefault() { async function resetDefault() {
try { try {
await ResetDatabaseToDefault(); await ResetDatabaseToDefault();
await refreshDb(); await refreshDb();
setDbMsg('Database reset to the default location. Restart OpsLog to apply.'); setDbMsg(dbSettings.default_path || '');
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
// Switching the logbook backend applies immediately (no restart): the local
// SQLite file always stays the config store; only the QSO logbook moves.
function useLocalLogbook() {
SaveMySQLSettings({ ...mysqlCfg, enabled: false } as any)
.then(async () => { setMysqlField({ enabled: false }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
.catch((e: any) => setErr(String(e?.message ?? e)));
}
function connectMysql() {
SaveMySQLSettings(mysqlCfg as any)
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
.catch((e: any) => setErr(String(e?.message ?? e)));
}
return ( return (
<> <>
<SectionHeader <SectionHeader title={t('sec.database')} />
title={t('sec.database')}
/> {/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op).
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can Switching is instant no restart. Only changing the local SQLite
target its own database; choosing here and Save switches the live FILE needs a restart (it also stores this operator's settings). */}
logbook immediately (no restart). */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1"> <div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">Backend</Label> <Label className="text-sm">{t('db.backend')}</Label>
<Select <Select
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })} onValueChange={(v) => {
const enabled = v === 'mysql';
setMysqlField({ enabled });
setRestartMsg('');
if (!enabled) useLocalLogbook(); // switching to local applies at once
}}
> >
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -3462,50 +3578,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3"> <p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
{t('db.profileHint')}
</p>
{/* Save (always visible) applies the active profile's DB target live. */} {/* Compact active-backend confirmation / MySQL-fallback warning. */}
<div className="max-w-2xl mb-4 flex items-center gap-3">
<Button size="sm" className="h-8"
onClick={() => {
SaveMySQLSettings(mysqlCfg as any)
.then(() => setRestartMsg(mysqlCfg.enabled
? t('db.switchedMysql')
: t('db.switchedSqlite')))
.catch((e: any) => setErr(String(e?.message ?? e)));
}}>
{t('db.saveSwitch')}
</Button>
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
</div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && ( {backendStatus && (
<div className="max-w-2xl mb-4"> backendStatus.fallback ? (
{backendStatus.fallback ? ( <div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 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>
) : backendStatus.active === 'mysql' ? (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
<strong>{t('db.logbook')}</strong> {t('db.mysqlConnected')}
<span className="mx-1.5 text-muted-foreground">·</span>
<strong>{t('db.config')}</strong> {t('db.localSqlite')}
<div className="text-[10px] text-muted-foreground mt-1">
{t('db.mysqlNote')}
</div>
</div>
) : ( ) : (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2"> <div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
{t('db.activeBackend')} <strong className="uppercase">{backendStatus.active}</strong> {t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
</div> {backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
)}
</div> </div>
)
)} )}
{/* SQLite: local logbook file management */}
{!mysqlCfg.enabled && ( {!mysqlCfg.enabled && (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="space-y-1"> <div className="space-y-1">
@@ -3513,7 +3603,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>
@@ -3521,26 +3611,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button> <Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}>{t('db.openExisting')}</Button> <Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>{t('db.saveCopy')}</Button> <Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>} {dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</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-3 space-y-2">
<span>{dbMsg}</span> <div className="flex items-start gap-2">
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>{t('db.quitNow')}</Button> <Check className="size-4 mt-0.5 shrink-0" />
<div>
<div className="font-medium">{t('db.savedRestart')}</div>
<div className="font-mono text-[10px] mt-1 break-all opacity-90">{dbMsg}</div>
</div>
</div>
<div className="flex items-center gap-2 pl-6">
<Button size="sm" onClick={() => { RestartApp().catch((e: any) => setErr(String(e?.message ?? e))); }}>
<Power className="size-3.5" /> {t('db.restartNow')}
</Button>
<span className="text-[10px] opacity-80">{t('db.restartHint')}</span>
</div>
</div> </div>
)} )}
</div> </div>
)} )}
{/* Shared MySQL database (multi-operator) */} {/* MySQL: shared logbook connection (multi-operator) */}
{mysqlCfg.enabled && ( {mysqlCfg.enabled && (
<div className="space-y-3 max-w-2xl"> <div className="space-y-3 max-w-2xl">
<div className="text-[11px] text-muted-foreground leading-relaxed"> <div className="text-[11px] text-muted-foreground leading-relaxed">{t('db.mysqlHint')}</div>
{t('db.mysqlHint')}
</div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center"> <div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">{t('db.host')}</Label> <Label className="text-sm">{t('db.host')}</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} /> <Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
@@ -3553,28 +3652,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('es.password')}</Label> <Label className="text-sm">{t('es.password')}</Label>
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} /> <Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-wrap">
<Button variant="outline" size="sm" className="h-8" <Button variant="outline" size="sm" className="h-8"
onClick={() => { setMysqlMsg(t('db.testing')); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg(t('db.connectedReady'))).catch((e: any) => setMysqlMsg(t('db.failed') + String(e?.message ?? e))); }}> onClick={() => { setMysqlMsg(t('db.testing')); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg(t('db.connectedReady'))).catch((e: any) => setMysqlMsg(t('db.failed') + String(e?.message ?? e))); }}>
{t('db.testCreate')} {t('db.testCreate')}
</Button> </Button>
<Button size="sm" className="h-8" onClick={connectMysql}>{t('db.connectUse')}</Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span> <span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div> </div>
</div> </div>
)} )}
{/* Data location */} {restartMsg && <div className="max-w-2xl mt-3 text-[11px] text-success">{restartMsg}</div>}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div>
<div className="text-sm font-medium">{t('db.dataLocation')}</div>
</div>
<div className="space-y-1">
<Label>{t('db.currentDataDir')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dataDir || '—'}
</div>
</div>
</div>
{/* Backup settings, merged into this Database section. */} {/* Backup settings, merged into this Database section. */}
<div className="border-t border-border/60 mt-6 pt-5"> <div className="border-t border-border/60 mt-6 pt-5">
@@ -3790,6 +3879,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')}
@@ -3822,8 +3913,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'
}> }>
@@ -24,7 +24,7 @@ type UDPConfig = {
direction: 'inbound' | 'outbound'; direction: 'inbound' | 'outbound';
name: string; name: string;
port: number; port: number;
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated'; service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo';
multicast: boolean; multicast: boolean;
multicast_group: string; multicast_group: string;
destination_ip: string; destination_ip: string;
@@ -77,6 +77,20 @@ const SERVICE_TYPES: Array<{
hint: 'udpp.svcDbHint', hint: 'udpp.svcDbHint',
defaults: { port: 2333, destination_ip: '127.0.0.1' }, defaults: { port: 2333, destination_ip: '127.0.0.1' },
}, },
{
id: 'pstrotator_freq',
direction: 'outbound',
label: 'udpp.svcPstLabel',
hint: 'udpp.svcPstHint',
defaults: { port: 12040, destination_ip: '127.0.0.1' },
},
{
id: 'n1mm_radioinfo',
direction: 'outbound',
label: 'udpp.svcN1mmRadioLabel',
hint: 'udpp.svcN1mmRadioHint',
defaults: { port: 12060, destination_ip: '127.0.0.1' },
},
]; ];
type Props = { onError: (msg: string) => void }; type Props = { onError: (msg: string) => void };
+37 -7
View File
@@ -26,6 +26,9 @@ interface Props {
wpm: number; wpm: number;
macros: WKMacro[]; macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void; onSelectPort: (p: string) => void;
onRefreshPorts: () => void; onRefreshPorts: () => void;
onConnect: () => void; onConnect: () => void;
@@ -49,7 +52,7 @@ interface Props {
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer // reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
// hardware: free-text CW, one-click macros (F1…), live speed, and abort. // hardware: free-text CW, one-click macros (F1…), live speed, and abort.
export function WinkeyerPanel({ export function WinkeyerPanel({
status, ports, port, wpm, macros, sent, status, ports, port, wpm, macros, sent, source, breakIn = 0, onSetBreakIn,
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed, onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose, onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace, sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
@@ -96,11 +99,18 @@ export function WinkeyerPanel({
{/* Header / connection bar */} {/* Header / connection bar */}
<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">
<Radio className="size-4 text-primary shrink-0" /> <Radio className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span> {/* CW output engine (chosen in Settings → CW Keyer). */}
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')} <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
</span>
<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" />
{!connected ? ( {source === 'icom' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
</span>
) : !connected ? (
<> <>
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}> <Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger> <SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
@@ -132,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>}
@@ -150,17 +160,37 @@ export function WinkeyerPanel({
</div> </div>
</div> </div>
{/* Break-in (Icom only): the rig's 0x17 keyer only transmits when break-in
is on SEMI or FULL. OFF keys the sidetone but stays in RX. */}
{source === 'icom' && (
<div className="flex items-center gap-2">
<Label className="text-xs w-16 shrink-0" title={t('wkp.breakInHint')}>{t('wkp.breakIn')}</Label>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{[{ v: 0, l: t('wkp.bkOff') }, { v: 1, l: 'SEMI' }, { v: 2, l: 'FULL' }].map((o) => (
<button key={o.v} type="button" disabled={!connected} onClick={() => onSetBreakIn?.(o.v)}
className={cn('px-2.5 py-1 text-[11px] font-bold tracking-wide border-l border-border first:border-l-0 transition-colors disabled:opacity-40',
breakIn === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
{breakIn === 0 && <span className="text-[10px] text-warning">{t('wkp.bkOffWarn')}</span>}
</div>
)}
{/* CW text */} {/* CW text */}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2"> <Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
{t('wkp.cwText')} {t('wkp.cwText')}
{source === 'winkeyer' && (
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground" <label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title={t('wkp.sendOnTypeHint')}> title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType} <input type="checkbox" className="accent-primary" checked={sendOnType}
onChange={(e) => onToggleSendOnType(e.target.checked)} /> onChange={(e) => onToggleSendOnType(e.target.checked)} />
{t('wkp.sendOnType')} {t('wkp.sendOnType')}
</label> </label>
)}
</Label> </Label>
<Input <Input
value={cwText} value={cwText}
@@ -195,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. */}
@@ -217,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,
});
+26 -8
View File
@@ -20,7 +20,7 @@ const en: Dict = {
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters', 'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…', 'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)', 'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…', 'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
// Duplicates modal // Duplicates modal
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉', 'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
@@ -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',
@@ -122,7 +125,7 @@ const en: Dict = {
// Section hints (hardware/software panel headers) // Section hints (hardware/software panel headers)
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.', 'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).", 'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).', 'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
@@ -146,6 +149,8 @@ const en: Dict = {
// Database panel // Database panel
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.', 'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓', 'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:', 'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now', 'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ', 'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
@@ -168,6 +173,7 @@ const en: Dict = {
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)', 'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)',
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.', 'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map', 'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)', 'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).', 'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name', 'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
@@ -177,19 +183,22 @@ const en: Dict = {
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field', 'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
// Hardware panels (winkeyer / dvk / antgenius / flex / icom) // Hardware panels (winkeyer / dvk / antgenius / flex / icom)
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer', 'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rigs own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop', 'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active',
'rst.clickToFill': 'Click to set RST tx from the signal', 'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com',
// Misc panels/modals (alerts / send-spot / net / udp / filter / details) // Misc panels/modals (alerts / send-spot / net / udp / filter / details)
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close', 'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot', 'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET', 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'DB updated → notify other apps', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', 'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', 'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
// Awards (ref picker / ref selector / awards panel / award editor) // Awards (ref picker / ref selector / awards panel / award editor)
@@ -216,7 +225,7 @@ const fr: Dict = {
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres', 'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…', 'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)', 'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…', 'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉', 'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.', 'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
@@ -235,6 +244,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',
@@ -304,7 +316,7 @@ const fr: Dict = {
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans', 'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans',
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.", 'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).", 'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).", 'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
@@ -324,6 +336,8 @@ const fr: Dict = {
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.', 'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.', 'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓', 'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :', 'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant', 'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ', 'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
@@ -344,6 +358,7 @@ const fr: Dict = {
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)', 'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)',
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.', 'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande', 'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)', 'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).', 'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom', 'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
@@ -352,18 +367,21 @@ const fr: Dict = {
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande', 'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ', 'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer', 'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de lIcom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop', 'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer', 'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot', 'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET', 'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "Base mise à jour → notifier d'autres apps", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', 'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
+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); }
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.16.5'; export const APP_VERSION = '0.17';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+16
View File
@@ -342,6 +342,8 @@ export function IcomRefresh():Promise<void>;
export function IcomScopeData():Promise<cat.ScopeSweep>; export function IcomScopeData():Promise<cat.ScopeSweep>;
export function IcomSendCW(arg1:string):Promise<void>;
export function IcomSetAFGain(arg1:number):Promise<void>; export function IcomSetAFGain(arg1:number):Promise<void>;
export function IcomSetAGC(arg1:string):Promise<void>; export function IcomSetAGC(arg1:string):Promise<void>;
@@ -350,8 +352,12 @@ export function IcomSetANF(arg1:boolean):Promise<void>;
export function IcomSetAtt(arg1:number):Promise<void>; export function IcomSetAtt(arg1:number):Promise<void>;
export function IcomSetBreakIn(arg1:number):Promise<void>;
export function IcomSetFilter(arg1:number):Promise<void>; export function IcomSetFilter(arg1:number):Promise<void>;
export function IcomSetKeySpeed(arg1:number):Promise<void>;
export function IcomSetMicGain(arg1:number):Promise<void>; export function IcomSetMicGain(arg1:number):Promise<void>;
export function IcomSetNB(arg1:boolean):Promise<void>; export function IcomSetNB(arg1:boolean):Promise<void>;
@@ -370,12 +376,20 @@ export function IcomSetRFGain(arg1:number):Promise<void>;
export function IcomSetRFPower(arg1:number):Promise<void>; export function IcomSetRFPower(arg1:number):Promise<void>;
export function IcomSetRIT(arg1:number):Promise<void>;
export function IcomSetRITOn(arg1:boolean):Promise<void>;
export function IcomSetScope(arg1:boolean):Promise<void>; export function IcomSetScope(arg1:boolean):Promise<void>;
export function IcomSetScopeMode(arg1:boolean):Promise<void>; export function IcomSetScopeMode(arg1:boolean):Promise<void>;
export function IcomSetSplit(arg1:boolean):Promise<void>; export function IcomSetSplit(arg1:boolean):Promise<void>;
export function IcomSetXITOn(arg1:boolean):Promise<void>;
export function IcomStopCW():Promise<void>;
export function IcomTune():Promise<void>; export function IcomTune():Promise<void>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>; export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
@@ -538,6 +552,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>; export function ResetDatabaseToDefault():Promise<void>;
export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>; export function RestartQSORecorder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
+32
View File
@@ -646,6 +646,10 @@ export function IcomScopeData() {
return window['go']['main']['App']['IcomScopeData'](); return window['go']['main']['App']['IcomScopeData']();
} }
export function IcomSendCW(arg1) {
return window['go']['main']['App']['IcomSendCW'](arg1);
}
export function IcomSetAFGain(arg1) { export function IcomSetAFGain(arg1) {
return window['go']['main']['App']['IcomSetAFGain'](arg1); return window['go']['main']['App']['IcomSetAFGain'](arg1);
} }
@@ -662,10 +666,18 @@ export function IcomSetAtt(arg1) {
return window['go']['main']['App']['IcomSetAtt'](arg1); return window['go']['main']['App']['IcomSetAtt'](arg1);
} }
export function IcomSetBreakIn(arg1) {
return window['go']['main']['App']['IcomSetBreakIn'](arg1);
}
export function IcomSetFilter(arg1) { export function IcomSetFilter(arg1) {
return window['go']['main']['App']['IcomSetFilter'](arg1); return window['go']['main']['App']['IcomSetFilter'](arg1);
} }
export function IcomSetKeySpeed(arg1) {
return window['go']['main']['App']['IcomSetKeySpeed'](arg1);
}
export function IcomSetMicGain(arg1) { export function IcomSetMicGain(arg1) {
return window['go']['main']['App']['IcomSetMicGain'](arg1); return window['go']['main']['App']['IcomSetMicGain'](arg1);
} }
@@ -702,6 +714,14 @@ export function IcomSetRFPower(arg1) {
return window['go']['main']['App']['IcomSetRFPower'](arg1); return window['go']['main']['App']['IcomSetRFPower'](arg1);
} }
export function IcomSetRIT(arg1) {
return window['go']['main']['App']['IcomSetRIT'](arg1);
}
export function IcomSetRITOn(arg1) {
return window['go']['main']['App']['IcomSetRITOn'](arg1);
}
export function IcomSetScope(arg1) { export function IcomSetScope(arg1) {
return window['go']['main']['App']['IcomSetScope'](arg1); return window['go']['main']['App']['IcomSetScope'](arg1);
} }
@@ -714,6 +734,14 @@ export function IcomSetSplit(arg1) {
return window['go']['main']['App']['IcomSetSplit'](arg1); return window['go']['main']['App']['IcomSetSplit'](arg1);
} }
export function IcomSetXITOn(arg1) {
return window['go']['main']['App']['IcomSetXITOn'](arg1);
}
export function IcomStopCW() {
return window['go']['main']['App']['IcomStopCW']();
}
export function IcomTune() { export function IcomTune() {
return window['go']['main']['App']['IcomTune'](); return window['go']['main']['App']['IcomTune']();
} }
@@ -1038,6 +1066,10 @@ export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault'](); return window['go']['main']['App']['ResetDatabaseToDefault']();
} }
export function RestartApp() {
return window['go']['main']['App']['RestartApp']();
}
export function RestartQSORecorder() { export function RestartQSORecorder() {
return window['go']['main']['App']['RestartQSORecorder'](); return window['go']['main']['App']['RestartQSORecorder']();
} }
+12
View File
@@ -676,6 +676,11 @@ export namespace cat {
s_meter: number; s_meter: number;
power_meter: number; power_meter: number;
swr_meter: number; swr_meter: number;
rit_hz: number;
rit_on: boolean;
xit_on: boolean;
key_speed_wpm: number;
break_in: number;
rf_power: number; rf_power: number;
mic_gain: number; mic_gain: number;
af_gain: number; af_gain: number;
@@ -704,6 +709,11 @@ export namespace cat {
this.s_meter = source["s_meter"]; this.s_meter = source["s_meter"];
this.power_meter = source["power_meter"]; this.power_meter = source["power_meter"];
this.swr_meter = source["swr_meter"]; this.swr_meter = source["swr_meter"];
this.rit_hz = source["rit_hz"];
this.rit_on = source["rit_on"];
this.xit_on = source["xit_on"];
this.key_speed_wpm = source["key_speed_wpm"];
this.break_in = source["break_in"];
this.rf_power = source["rf_power"]; this.rf_power = source["rf_power"];
this.mic_gain = source["mic_gain"]; this.mic_gain = source["mic_gain"];
this.af_gain = source["af_gain"]; this.af_gain = source["af_gain"];
@@ -1079,6 +1089,7 @@ export namespace main {
export class AntGeniusSettings { export class AntGeniusSettings {
enabled: boolean; enabled: boolean;
host: string; host: string;
password: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AntGeniusSettings(source); return new AntGeniusSettings(source);
@@ -1088,6 +1099,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.host = source["host"]; this.host = source["host"];
this.password = source["password"];
} }
} }
export class AudioSettings { export class AudioSettings {
+81 -9
View File
@@ -16,7 +16,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"hamlog/internal/applog"
) )
const ( const (
@@ -49,10 +52,16 @@ type Status struct {
type Client struct { type Client struct {
host string host string
port int port int
password string // remote-access password; sent as "login <pw>" when the device requires AUTH
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)
@@ -61,13 +70,14 @@ type Client struct {
running bool running bool
} }
func New(host string, port int) *Client { func New(host string, port int, password string) *Client {
if port <= 0 || port > 65535 { if port <= 0 || port > 65535 {
port = defaultPort port = defaultPort
} }
return &Client{ return &Client{
host: host, host: host,
port: port, port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}), stop: make(chan struct{}),
antennas: map[int]string{}, antennas: map[int]string{},
status: Status{Host: host}, status: Status{Host: host},
@@ -146,13 +156,13 @@ func (c *Client) runLoop() {
c.conn = conn c.conn = conn
c.mu.Unlock() c.mu.Unlock()
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())
// Subscribe to live updates and pull the initial state. Command set and // Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a
// order mirror a known-working Node-RED v4 client (WA9WUD). // password we send "auth code=<pw>" and only send the subscribe/get commands
_ = c.send("antenna list") // once it's accepted (retrying on FF); without AUTH they go out immediately.
_ = c.send("sub port all") c.awaitingAuth, c.authTries = false, 0
_ = c.send("port get 1") c.ready.Store(false)
_ = c.send("port get 2")
done := make(chan struct{}) done := make(chan struct{})
go c.keepalive(conn, done) go c.keepalive(conn, done)
@@ -189,23 +199,51 @@ 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
start := time.Now()
lines, bytesRx := 0, 0
for { for {
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout)) _ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
b, err := r.ReadByte() b, err := r.ReadByte()
if err != nil { if err != nil {
// Log where the link died: 0 lines after connect points at the device
// refusing/closing (e.g. a client-slot limit or source-IP rejection —
// common when reaching it remotely); an EOF after the banner/replies
// points at something later in the exchange.
applog.Printf("antgenius: read ended after %s — %d line(s), %d byte(s): %v",
time.Since(start).Round(time.Millisecond), lines, bytesRx, err)
return err return err
} }
bytesRx++
if b == '\r' || b == '\n' { if b == '\r' || b == '\n' {
if sb.Len() > 0 { if sb.Len() > 0 {
lines++
if lines <= 10 {
applog.Printf("antgenius: rx[%d] %q", lines, sb.String())
}
c.handleLine(sb.String()) c.handleLine(sb.String())
sb.Reset() sb.Reset()
} }
@@ -236,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]
} }
@@ -256,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)
+4 -4
View File
@@ -3,7 +3,7 @@ package antgenius
import "testing" import "testing"
func TestHandleAntennaList(t *testing.T) { func TestHandleAntennaList(t *testing.T) {
c := New("x", 9007) c := New("x", 9007, "")
// Names may contain spaces — must be captured up to " tx=". // Names may contain spaces — must be captured up to " tx=".
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000") c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000") c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
@@ -20,7 +20,7 @@ func TestHandleAntennaList(t *testing.T) {
} }
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) { func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
c := New("x", 9007) c := New("x", 9007, "")
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
@@ -31,7 +31,7 @@ func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
} }
func TestHandlePortStatus(t *testing.T) { func TestHandlePortStatus(t *testing.T) {
c := New("x", 9007) c := New("x", 9007, "")
// Async push after "sub port all": active antenna is txant (fallback rxant). // Async push after "sub port all": active antenna is txant (fallback rxant).
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0") c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0") c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
@@ -50,7 +50,7 @@ func TestHandlePortStatus(t *testing.T) {
} }
func TestPortTxFallbackToRx(t *testing.T) { func TestPortTxFallbackToRx(t *testing.T) {
c := New("x", 9007) c := New("x", 9007, "")
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0") c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
if st := c.GetStatus(); st.PortA != 3 { if st := c.GetStatus(); st.PortA != 3 {
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA) t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
+14
View File
@@ -382,6 +382,13 @@ type IcomTXState struct {
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120) SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
PowerMeter int `json:"power_meter"` // 0-100 (TX Po) PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR) SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
// RIT / ΔTX (XIT).
RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz
RITOn bool `json:"rit_on"`
XITOn bool `json:"xit_on"`
// CW keyer (send messages via the rig's internal keyer, CI-V 0x17).
KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL
// Set controls. // Set controls.
RFPower int `json:"rf_power"` // 0-100 (TX output) RFPower int `json:"rf_power"` // 0-100 (TX output)
MicGain int `json:"mic_gain"` // 0-100 MicGain int `json:"mic_gain"` // 0-100
@@ -422,6 +429,13 @@ type IcomController interface {
SetScope(bool) error // enable/disable the spectrum-scope waveform stream SetScope(bool) error // enable/disable the spectrum-scope waveform stream
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled) ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
SetRIT(int) error // RIT/ΔTX offset in signed Hz
SetRITOn(bool) error // RIT on/off
SetXITOn(bool) error // ΔTX (XIT) on/off
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
StopCW() error // abort the CW message being sent
SetKeySpeed(int) error // CW keyer speed in WPM
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
} }
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's // ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
+99
View File
@@ -44,6 +44,20 @@ const (
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte) CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune) CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off) CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
// CW keyer speed range for the KEY SPEED level (IC-7610: 6-48 WPM).
KeyMinWPM = 6
KeyMaxWPM = 48
StopCWByte = 0xFF // 0x17 data byte that stops an in-progress CW message
SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -)
SubRITOn = 0x01 // RIT on/off (00/01)
SubXITOn = 0x02 // ΔTX (XIT) on/off (00/01)
SubDataMode = 0x06 SubDataMode = 0x06
SubPTT = 0x00 SubPTT = 0x00
@@ -80,6 +94,14 @@ const (
SubSwNB = 0x22 // noise blanker on/off SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off SubSwANF = 0x41 // auto-notch on/off
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
)
// CW break-in modes (CmdSwitch 0x47).
const (
BreakInOff = 0
BreakInSemi = 1
BreakInFull = 2
) )
// Icom mode codes (used by CmdReadMode / CmdSetMode). // Icom mode codes (used by CmdReadMode / CmdSetMode).
@@ -154,6 +176,83 @@ func BCDToLevel(b []byte) int {
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F) return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
} }
// RITToBCD encodes a RIT/ΔTX offset (Hz, 9999..9999) as the 3 bytes CI-V
// command 0x21 0x00 uses: 2 little-endian BCD bytes of the magnitude followed by
// a sign byte (0x00 positive, 0x01 negative).
func RITToBCD(hz int) []byte {
neg := hz < 0
if neg {
hz = -hz
}
if hz > 9999 {
hz = 9999
}
lo := byte(hz%10 | (hz/10%10)<<4)
hi := byte(hz/100%10 | (hz/1000%10)<<4)
sign := byte(0)
if neg {
sign = 1
}
return []byte{lo, hi, sign}
}
// BCDToRIT decodes the 3 offset bytes of a 0x21 0x00 response back to signed Hz.
func BCDToRIT(b []byte) int {
if len(b) < 3 {
return 0
}
v := int(b[0]&0x0F) + int(b[0]>>4)*10 + int(b[1]&0x0F)*100 + int(b[1]>>4)*1000
if b[2] != 0 {
return -v
}
return v
}
// WPMToKeyLevel maps a CW speed in words-per-minute to the 0-255 value the KEY
// SPEED level (CmdLevel 0x0C) expects, linear across KeyMinWPM..KeyMaxWPM.
func WPMToKeyLevel(wpm int) int {
if wpm < KeyMinWPM {
wpm = KeyMinWPM
}
if wpm > KeyMaxWPM {
wpm = KeyMaxWPM
}
return (wpm - KeyMinWPM) * 255 / (KeyMaxWPM - KeyMinWPM)
}
// KeyLevelToWPM is the inverse of WPMToKeyLevel (0-255 → WPM).
func KeyLevelToWPM(v int) int {
if v < 0 {
v = 0
}
if v > 255 {
v = 255
}
return KeyMinWPM + (v*(KeyMaxWPM-KeyMinWPM)+127)/255
}
// CWText is the set of characters the rig's keyer accepts (command 0x17).
// Everything else is dropped. Space keys a word gap.
const CWText = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+@:"
// FilterCW upper-cases text and keeps only keyer-legal characters.
func FilterCW(text string) string {
out := make([]byte, 0, len(text))
for i := 0; i < len(text); i++ {
c := text[i]
if c >= 'a' && c <= 'z' {
c -= 32
}
for j := 0; j < len(CWText); j++ {
if CWText[j] == c {
out = append(out, c)
break
}
}
}
return string(out)
}
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the // ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…). // attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
func ByteToBCD(v int) byte { func ByteToBCD(v int) byte {
+205 -13
View File
@@ -12,18 +12,38 @@ import (
"go.bug.st/serial" "go.bug.st/serial"
) )
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local // civTransport is everything IcomSerial needs from its underlying link. It's
// control). It speaks the shared civ protocol, so when the network backend // satisfied directly by a go.bug.st serial.Port (USB, local control) and, for
// (icomnet) is added it will reuse the same encode/decode — only the transport // remote control, by the icomnet UDP stream which presents the tunnelled CI-V
// changes. Implements Backend; all methods run on the Manager's CAT goroutine, // byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are
// so the port is accessed single-threaded (no locking needed). // no-ops). Abstracting it here means the whole IcomController surface — DSP,
// scope, RIT, CW — is reused unchanged over the network; only `open` differs.
type civTransport interface {
Read(p []byte) (int, error)
Write(p []byte) (int, error)
Close() error
SetReadTimeout(time.Duration) error
SetDTR(bool) error
SetRTS(bool) error
}
// IcomSerial controls an Icom transceiver over the shared civ protocol. The
// transport is pluggable via `open`: NewIcomSerial opens a USB/serial port;
// NewIcomNet (later) returns one configured with a network transport. Implements
// Backend; all methods run on the Manager's CAT goroutine, so the port is
// accessed single-threaded (no locking needed).
type IcomSerial struct { type IcomSerial struct {
portName string portName string
baud int baud int
rigAddr byte // rig's CI-V address (IC-7610 default 0x98) rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
digital string // mode to command for DATA (FT8/RTTY/…) digital string // mode to command for DATA (FT8/RTTY/…)
port serial.Port // open dials the underlying link (serial port or network stream). Set by the
// constructor; called by Connect. Blocks until connected (the network opener
// performs the full UDP handshake + login before returning).
open func() (civTransport, error)
port civTransport
model string model string
// I/O routing. A single reader goroutine owns port.Read and dispatches every // I/O routing. A single reader goroutine owns port.Read and dispatches every
@@ -82,7 +102,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
if digitalDefault == "" { if digitalDefault == "" {
digitalDefault = "FT8" digitalDefault = "FT8"
} }
return &IcomSerial{ b := &IcomSerial{
portName: portName, portName: portName,
baud: baud, baud: baud,
rigAddr: byte(civAddr), rigAddr: byte(civAddr),
@@ -90,20 +110,32 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
model: "Icom", model: "Icom",
scopeFixed: true, // rigs default to a fixed-span scope scopeFixed: true, // rigs default to a fixed-span scope
} }
// USB/serial transport opener.
b.open = func() (civTransport, error) {
if b.portName == "" {
return nil, fmt.Errorf("no serial port configured")
}
p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
if err != nil {
return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
}
return p, nil
}
return b
} }
func (b *IcomSerial) Name() string { return "icom" } func (b *IcomSerial) Name() string { return "icom" }
func (b *IcomSerial) Connect() error { func (b *IcomSerial) Connect() error {
if b.portName == "" { if b.open == nil {
return fmt.Errorf("no serial port configured") return fmt.Errorf("no transport configured")
} }
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud}) port, err := b.open()
if err != nil { if err != nil {
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err) return err
} }
// Short read timeout so recv() polls in a tight loop without blocking the // Short read timeout so recv() polls in a tight loop without blocking the
// CAT goroutine when the rig is silent. // CAT goroutine when the rig is silent. (No-op on the network transport.)
_ = port.SetReadTimeout(60 * time.Millisecond) _ = port.SetReadTimeout(60 * time.Millisecond)
// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and // Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and
// "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with // "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with
@@ -277,6 +309,12 @@ func (b *IcomSerial) SetPTT(on bool) error {
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
func (b *IcomSerial) write(payload ...byte) error { func (b *IcomSerial) write(payload ...byte) error {
// Not connected (rig off / port dropped): fail cleanly instead of
// dereferencing a nil port — a Set* dispatched while disconnected (e.g.
// clicking Scope ON with the radio off) would otherwise panic the app.
if b.port == nil {
return fmt.Errorf("icom: not connected")
}
// Drop any stale/unsolicited frames buffered from before this command so // Drop any stale/unsolicited frames buffered from before this command so
// recv() only sees the reply to THIS request (avoids a previous command's ack // recv() only sees the reply to THIS request (avoids a previous command's ack
// or an unsolicited dial-turn update being mistaken for our response). // or an unsolicited dial-turn update being mistaken for our response).
@@ -306,7 +344,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
// frames and routes each: our own echoes are dropped, spectrum-scope frames // frames and routes each: our own echoes are dropped, spectrum-scope frames
// (0x27) go to specCh, everything else (control replies, acks, unsolicited // (0x27) go to specCh, everything else (control replies, acks, unsolicited
// transceive updates) goes to respCh. It exits when the port is closed. // transceive updates) goes to respCh. It exits when the port is closed.
func (b *IcomSerial) reader(port serial.Port, done chan struct{}) { func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
defer close(done) defer close(done)
tmp := make([]byte, 512) tmp := make([]byte, 512)
var rx []byte var rx []byte
@@ -514,6 +552,153 @@ func (b *IcomSerial) SetScopeMode(fixed bool) error {
return nil return nil
} }
// SetRIT sets the RIT/ΔTX offset (signed Hz, ±9999).
func (b *IcomSerial) SetRIT(hz int) error {
if err := b.exec(append([]byte{civ.CmdRIT, civ.SubRITFreq}, civ.RITToBCD(hz)...)...); err != nil {
return err
}
if hz < -9999 {
hz = -9999
}
if hz > 9999 {
hz = 9999
}
b.setCache(func(s *IcomTXState) { s.RITHz = hz })
return nil
}
func (b *IcomSerial) SetRITOn(on bool) error {
if err := b.exec(civ.CmdRIT, civ.SubRITOn, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.RITOn = on })
return nil
}
func (b *IcomSerial) SetXITOn(on bool) error {
if err := b.exec(civ.CmdRIT, civ.SubXITOn, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.XITOn = on })
return nil
}
// SendCW keys a CW message through the rig's internal keyer (CI-V 0x17). The
// text is upper-cased and filtered to keyer-legal characters; the radio must be
// in CW mode. Messages longer than 30 characters are split across several 0x17
// commands (the rig queues them).
func (b *IcomSerial) SendCW(text string) error {
msg := civ.FilterCW(text)
if msg == "" {
applog.Printf("icom cw: nothing to send (filtered %q → empty)", text)
return nil
}
applog.Printf("icom cw: send %q (%d chars) to rig 0x%02X", msg, len(msg), b.rigAddr)
for len(msg) > 0 {
n := len(msg)
if n > 30 {
n = 30
}
chunk := msg[:n]
msg = msg[n:]
payload := append([]byte{civ.CmdSendCW}, []byte(chunk)...)
if err := b.write(payload...); err != nil {
applog.Printf("icom cw: write failed: %v", err)
return err
}
// A missing ack is NOT fatal: some firmwares don't acknowledge 0x17, and
// the message bytes were already written. Only an explicit NG (0xFA) means
// the rig refused it (typically: not in CW mode / break-in off).
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
if err != nil {
applog.Printf("icom cw: chunk %q written, no ack (sent anyway): %v", chunk, err)
} else if f.Cmd == civ.NG {
applog.Printf("icom cw: rig REJECTED CW (0xFA) — put the rig in CW mode / enable break-in")
return fmt.Errorf("icom: rig rejected CW — check CW mode / break-in")
} else {
applog.Printf("icom cw: chunk %q acked OK", chunk)
}
}
return nil
}
// SetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL). Break-in must be on for
// the 0x17 CW keyer to actually switch the rig to transmit.
func (b *IcomSerial) SetBreakIn(mode int) error {
if mode < 0 {
mode = 0
}
if mode > 2 {
mode = 2
}
applog.Printf("icom cw: set break-in %d", mode)
if err := b.exec(civ.CmdSwitch, civ.SubSwBreakIn, byte(mode)); err != nil {
applog.Printf("icom cw: set break-in failed: %v", err)
return err
}
b.setCache(func(s *IcomTXState) { s.BreakIn = mode })
return nil
}
// StopCW aborts a CW message currently being sent (0x17 with the 0xFF stop code).
func (b *IcomSerial) StopCW() error {
applog.Printf("icom cw: stop")
if err := b.write(civ.CmdSendCW, civ.StopCWByte); err != nil {
return err
}
_, _ = b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
return nil
}
// SetKeySpeed sets the CW keyer speed in WPM (CmdLevel 0x0C).
func (b *IcomSerial) SetKeySpeed(wpm int) error {
lvl := civ.WPMToKeyLevel(wpm)
applog.Printf("icom cw: set key speed %d WPM (level %d)", wpm, lvl)
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelKeySpeed}, civ.LevelToBCD(lvl)...)...); err != nil {
applog.Printf("icom cw: set key speed failed: %v", err)
return err
}
if wpm < civ.KeyMinWPM {
wpm = civ.KeyMinWPM
}
if wpm > civ.KeyMaxWPM {
wpm = civ.KeyMaxWPM
}
b.setCache(func(s *IcomTXState) { s.KeySpeedWPM = wpm })
return nil
}
// readRIT reads the offset + RIT/ΔTX on-off flags into st (best-effort).
func (b *IcomSerial) readRIT(st *IcomTXState) {
if err := b.write(civ.CmdRIT, civ.SubRITFreq); err == nil {
if f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdRIT && len(d.Data) >= 4 && d.Data[0] == civ.SubRITFreq
}); err == nil {
st.RITHz = civ.BCDToRIT(f.Data[1:4])
}
}
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubRITOn); ok {
st.RITOn = v != 0
}
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubXITOn); ok {
st.XITOn = v != 0
}
}
// readSwitchSub reads a 1-byte on/off value for cmd+sub (generalises readSwitch).
func (b *IcomSerial) readSwitchSub(cmd, sub byte) (byte, bool) {
if err := b.write(cmd, sub); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == cmd && len(d.Data) >= 2 && d.Data[0] == sub
})
if err != nil {
return 0, false
}
return f.Data[1], true
}
// ScopeData returns a copy of the latest reassembled sweep as a number array. // ScopeData returns a copy of the latest reassembled sweep as a number array.
func (b *IcomSerial) ScopeData() ScopeSweep { func (b *IcomSerial) ScopeData() ScopeSweep {
b.scopeMu.Lock() b.scopeMu.Lock()
@@ -743,6 +928,13 @@ func (b *IcomSerial) readDSP() {
if _, f, ok := b.readModeFilter(); ok { if _, f, ok := b.readModeFilter(); ok {
st.Filter = int(f) st.Filter = int(f)
} }
b.readRIT(&st)
if v, ok := b.readLevel(civ.SubLevelKeySpeed); ok {
st.KeySpeedWPM = civ.KeyLevelToWPM(v)
}
if v, ok := b.readSwitch(civ.SubSwBreakIn); ok {
st.BreakIn = int(v)
}
b.dspMu.Lock() b.dspMu.Lock()
b.dsp = st b.dsp = st
+5
View File
@@ -74,6 +74,11 @@ var catalogue = []Def{
{"WW-DIGI", "World Wide Digi DX Contest", ExGrid}, {"WW-DIGI", "World Wide Digi DX Contest", ExGrid},
{"EU-HF-C", "EU HF Championship", ExSerial}, {"EU-HF-C", "EU HF Championship", ExSerial},
{"DARC-WAEDC", "WAEDC", ExSerial}, {"DARC-WAEDC", "WAEDC", ExSerial},
// EME (Earth-Moon-Earth) contests — the exchange is a signal report (TMO /
// RST), so there's no serial or fixed multiplier to prefill.
{"ARRL-EME", "ARRL EME Contest", ExRST},
{"DUBUS-REF-EME", "Dubus & REF EME Contest", ExRST},
{"ARI-EME", "ARI EME Contest", ExRST},
} }
// List returns the catalogue sorted by name (copy — callers must not mutate). // List returns the catalogue sorted by name (copy — callers must not mutate).
+22 -3
View File
@@ -60,6 +60,7 @@ type Decoder struct {
state bool // true = mark (key down) state bool // true = mark (key down)
stateHops int stateHops int
dotHops float64 // adaptive dot length, in hops dotHops float64 // adaptive dot length, in hops
markCount int // marks seen since lock (fast WPM adaptation while small)
elem []byte // current "." / "-" run for the in-progress character elem []byte // current "." / "-" run for the in-progress character
charEmitted bool charEmitted bool
wordEmitted bool wordEmitted bool
@@ -145,6 +146,7 @@ func (d *Decoder) Reset() {
d.state = false d.state = false
d.stateHops = 0 d.stateHops = 0
d.dotHops = 15 d.dotHops = 15
d.markCount = 0
d.elem = d.elem[:0] d.elem = d.elem[:0]
d.charEmitted, d.wordEmitted = false, false d.charEmitted, d.wordEmitted = false, false
} }
@@ -213,10 +215,11 @@ func (d *Decoder) analyze() {
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we // Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
// don't eat the first element of a strong signal), a marginal/weak tone // don't eat the first element of a strong signal), a marginal/weak tone
// locks after a couple of stable hops (so we don't lock onto pure noise). // locks after a couple of stable hops (so we don't lock onto pure noise).
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) { if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
d.lockIdx = maxIdx d.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0 d.quietHops = 0
d.markCount = 0 // relearn WPM fast for this new signal
} }
} }
if d.lockIdx >= 0 { if d.lockIdx >= 0 {
@@ -267,6 +270,17 @@ func (d *Decoder) step() {
} else { } else {
d.quietHops++ d.quietHops++
if d.quietHops > d.relockHops { if d.quietHops > d.relockHops {
// End of the over: flush any pending character and drop a word
// space so the next transmission starts a fresh word (the word-gap
// timer above can't fire once the lock is gone).
if len(d.elem) > 0 && !d.charEmitted {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && d.onChar != nil {
d.onChar(" ")
d.wordEmitted = true
}
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0 d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
} }
} }
@@ -311,9 +325,14 @@ func (d *Decoder) endMark(hops int) {
} }
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped // adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
// to ~5100 WPM). // to ~560 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
func (d *Decoder) adaptDot(obs float64) { func (d *Decoder) adaptDot(obs float64) {
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it const alpha = 0.2
d.markCount++
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100 if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 5 d.dotHops = 5
} }
+8 -5
View File
@@ -29,11 +29,14 @@ const (
type ServiceType string type ServiceType string
const ( const (
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary (inbound)
ServiceADIF ServiceType = "adif" // text ADIF over UDP ServiceADIF ServiceType = "adif" // text ADIF over UDP (inbound)
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML (inbound)
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign ServiceRemoteCall ServiceType = "remote_call" // plain text callsign (inbound)
ServiceDBUpdated ServiceType = "db_updated" // outbound ADIF of local QSO // Outbound emitters.
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
) )
// Config is one user-defined UDP connection. // Config is one user-defined UDP connection.
+104
View File
@@ -0,0 +1,104 @@
package udp
import (
"fmt"
"strings"
"hamlog/internal/applog"
)
// This file holds the outbound emitters: OpsLog → other programs over UDP.
// Formats are chosen per connection row (like the inbound parsers), so the user
// can point PstRotator, a second logger, an SDR, etc. at OpsLog.
// tensOfHz converts a frequency in Hz to the "tens of Hz" unit N1MM and
// PstRotator use for their frequency fields (e.g. 14 025 500 Hz → 1 402 550).
func tensOfHz(freqHz int64) int64 { return freqHz / 10 }
// BuildPstFreq builds the datagram PstRotatorAz expects for its "DXLog.net"
// tracker: <PST><FREQUENCY>{tens of Hz}</FREQUENCY></PST>. Verified by probing a
// live PstRotatorAz — it reads the value as tens of Hz (14025.5 kHz → 1402550 →
// displayed 3.5255 MHz for 3525.5 kHz, etc.).
func BuildPstFreq(freqHz int64) []byte {
return []byte(fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensOfHz(freqHz)))
}
// BuildN1MMRadioInfo builds an N1MM Logger+ RadioInfo XML datagram. <Freq> and
// <TXFreq> are in tens of Hz. Consumed by PstRotator (as the "N1MM Logger"
// tracker) and many other programs. mode is passed through (CW/USB/LSB/…).
func BuildN1MMRadioInfo(station string, rxFreqHz, txFreqHz int64, mode, opCall string) []byte {
if station == "" {
station = "OPSLOG"
}
if txFreqHz == 0 {
txFreqHz = rxFreqHz
}
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>`)
b.WriteString(`<RadioInfo>`)
b.WriteString(`<StationName>` + xmlEsc(station) + `</StationName>`)
b.WriteString(`<RadioNr>1</RadioNr>`)
b.WriteString(fmt.Sprintf(`<Freq>%d</Freq>`, tensOfHz(rxFreqHz)))
b.WriteString(fmt.Sprintf(`<TXFreq>%d</TXFreq>`, tensOfHz(txFreqHz)))
b.WriteString(`<Mode>` + xmlEsc(mode) + `</Mode>`)
b.WriteString(`<OpCall>` + xmlEsc(opCall) + `</OpCall>`)
b.WriteString(`<IsRunning>True</IsRunning>`)
b.WriteString(`<FocusEntry>0</FocusEntry>`)
b.WriteString(`<Antenna>0</Antenna>`)
b.WriteString(`<Rotors></Rotors>`)
b.WriteString(`<FocusRadioNr>1</FocusRadioNr>`)
b.WriteString(`<IsStereo>False</IsStereo>`)
b.WriteString(`<ActiveRadioNr>1</ActiveRadioNr>`)
b.WriteString(`</RadioInfo>`)
return []byte(b.String())
}
func xmlEsc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
// RadioState is a snapshot the app pushes to EmitRadioState on freq/mode change.
type RadioState struct {
StationName string
OpCall string
RxFreqHz int64 // operating/RX frequency
TxFreqHz int64 // TX frequency (may equal RX when not split)
Mode string
}
// EmitRadioState sends the current radio frequency/mode to every enabled
// outbound row whose format is frequency-based (PstRotator, N1MM RadioInfo).
// Best-effort: send errors are logged, never returned to the caller.
func (m *Manager) EmitRadioState(st RadioState) {
for _, c := range m.Outbound(ServicePstFreq) {
m.sendTo(c, BuildPstFreq(st.RxFreqHz))
}
for _, c := range m.Outbound(ServiceN1MMRadio) {
m.sendTo(c, BuildN1MMRadioInfo(st.StationName, st.RxFreqHz, st.TxFreqHz, st.Mode, st.OpCall))
}
}
// EmitLoggedADIF sends the ADIF of a just-logged QSO to every enabled outbound
// "ADIF message" row (db_updated) — lets a second logger or Cloudlog gateway
// pick up contacts as they're logged.
func (m *Manager) EmitLoggedADIF(adif string) {
if strings.TrimSpace(adif) == "" {
return
}
for _, c := range m.Outbound(ServiceDBUpdated) {
m.sendTo(c, []byte(adif))
}
}
// sendTo resolves the row's destination (host:port) and fires one datagram.
func (m *Manager) sendTo(c Config, payload []byte) {
host := strings.TrimSpace(c.DestinationIP)
if host == "" {
host = "127.0.0.1"
}
dst := fmt.Sprintf("%s:%d", host, c.Port)
if err := SendUDP(dst, payload); err != nil {
applog.Printf("udp: [%s] outbound send to %s failed: %v", c.Name, dst, err)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.16.5" appVersion = "0.17"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.