10 Commits
59 changed files with 2595 additions and 765 deletions
+148 -22
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"
@@ -151,8 +152,9 @@ const (
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP // Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
// 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")
@@ -8898,8 +9021,9 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error {
// AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel. // AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel.
// The TCP port is fixed at 9007 on the device, so only the IP is configurable. // The TCP port is fixed at 9007 on the device, so only the IP is configurable.
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
} }
@@ -8923,8 +9048,9 @@ func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
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?")
}
+204 -111
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
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500; // true for tens of seconds, which made the next CQ fire ~120s late.
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start if (cwSourceRef.current === 'icom') {
const deadline = Date.now() + capMs; await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50); } else {
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
const deadline = Date.now() + capMs;
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 && (
{t('tab.contest')} <TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-warning data-[state=active]:text-warning')}>
{contest.active && <span className="inline-block size-1.5 rounded-full bg-amber-500 animate-pulse" />} {t('tab.contest')}
</TabsTrigger> {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>
)}
{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>
<TabsContent value="contest" className="flex-1 min-h-0 p-0"> {contestTabEnabled && (
<ContestPanel session={contest} onChange={updateContest} /> <TabsContent value="contest" className="flex-1 min-h-0 p-0">
</TabsContent> <ContestPanel session={contest} onChange={updateContest} />
</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>
+248 -65
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
const mhz = (hz: number) => (hz / 1e6).toFixed(3); // pair isn't a usable low..high range, but the scope is centred on the VFO —
ctx.fillStyle = 'rgba(226,232,240,0.75)'; // so we always label the centre with the live VFO frequency (which we fetch
ctx.font = '10px ui-monospace, monospace'; // each sweep), and only add edge labels when the reported edges genuinely
ctx.textBaseline = 'bottom'; // bracket the VFO. That guarantees you always see your frequency in CTR.
ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2); const mhz = (hz: number) => (hz / 1e6).toFixed(3);
ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2); ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2); ctx.textBaseline = 'bottom';
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
ctx.fillStyle = 'rgba(226,232,240,0.85)';
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 && (
<canvas ref={canvasRef} onDoubleClick={onDblClick} <div className="p-3">
className="w-full block cursor-crosshair" style={{ height: 150 }} /> <div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
</div> <canvas ref={canvasRef} onDoubleClick={onDblClick}
</Card> className="w-full block cursor-crosshair" style={{ height: 140 }} />
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
</div>
</div>
)}
</div>
); );
} }
@@ -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>
+274 -183
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,91 +2390,115 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label> </label>
</div> </div>
<div className="grid grid-cols-4 gap-3"> {wk.engine === 'icom' ? (
<div className="space-y-1 col-span-2"> <>
<Label>Serial port</Label> <p className="text-xs text-muted-foreground -mt-2">
<div className="flex items-center gap-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).
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}> </p>
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger> {(!catCfg.enabled || catCfg.backend !== 'icom') && (
<SelectContent> <p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>} <span aria-hidden>⚠</span>
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)} <span>
</SelectContent> 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.
</Select> </span>
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports" </p>
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}> )}
<ArrowDown className="size-3.5 rotate-90" /> <div className="grid grid-cols-4 gap-3">
</Button> <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="space-y-1 col-span-2">
<Label>Serial port</Label>
<div className="flex items-center gap-2">
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div> </div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={5} max={99} 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"> <div className="space-y-1">
<Label>Weight</Label> <Label>Weight</Label>
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" /> <Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Lead-in (ms)</Label> <Label>Lead-in (ms)</Label>
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" /> <Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Tail (ms)</Label> <Label>Tail (ms)</Label>
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" /> <Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Ratio (33-66)</Label> <Label>Ratio (33-66)</Label>
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" /> <Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Farnsworth</Label> <Label>Farnsworth</Label>
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" /> <Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Sidetone (Hz)</Label> <Label>Sidetone (Hz)</Label>
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" /> <Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
</div> </div>
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Paddle mode</Label> <Label>Paddle mode</Label>
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}> <Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="iambic_b">Iambic B</SelectItem> <SelectItem value="iambic_b">Iambic B</SelectItem>
<SelectItem value="iambic_a">Iambic A</SelectItem> <SelectItem value="iambic_a">Iambic A</SelectItem>
<SelectItem value="ultimatic">Ultimatic</SelectItem> <SelectItem value="ultimatic">Ultimatic</SelectItem>
<SelectItem value="bug">Bug</SelectItem> <SelectItem value="bug">Bug</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-x-5 gap-y-2"> <div className="flex flex-wrap gap-x-5 gap-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles <Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space <Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT <Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<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="max-w-2xl mb-4 text-[11px] text-muted-foreground">
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2"> {t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
<strong>{t('db.logbook')}</strong> {t('db.mysqlConnected')} {backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
<span className="mx-1.5 text-muted-foreground">·</span> </div>
<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">
{t('db.activeBackend')} <strong className="uppercase">{backendStatus.active}</strong>
</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 };
+43 -13
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')}
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground" {source === 'winkeyer' && (
title={t('wkp.sendOnTypeHint')}> <label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
<input type="checkbox" className="accent-primary" checked={sendOnType} title={t('wkp.sendOnTypeHint')}>
onChange={(e) => onToggleSendOnType(e.target.checked)} /> <input type="checkbox" className="accent-primary" checked={sendOnType}
{t('wkp.sendOnType')} onChange={(e) => onToggleSendOnType(e.target.checked)} />
</label> {t('wkp.sendOnType')}
</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
+7 -1
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>
<App/> <ThemeProvider>
<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 {
+83 -11
View File
@@ -16,7 +16,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"hamlog/internal/applog"
) )
const ( const (
@@ -47,12 +50,18 @@ 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).
+23 -4
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,10 +325,15 @@ 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
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100 d.markCount++
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 5 d.dotHops = 5
} }
if d.dotHops > 55 { if d.dotHops > 55 {
+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.