feat: status bar added
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"sessionId":"9e3eadf5-7e35-4848-8cf9-515589d63e73","pid":5360,"acquiredAt":1780094656528}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -53,8 +54,11 @@ const (
|
||||
keyStationSOTA = "station.my_sota_ref"
|
||||
keyStationPOTA = "station.my_pota_ref"
|
||||
|
||||
keyListsBands = "lists.bands"
|
||||
keyListsModes = "lists.modes"
|
||||
keyListsBands = "lists.bands"
|
||||
keyListsModes = "lists.modes"
|
||||
keyListsRSTPhone = "lists.rst_phone"
|
||||
keyListsRSTCW = "lists.rst_cw"
|
||||
keyListsRSTDigital = "lists.rst_digital"
|
||||
|
||||
keyCATEnabled = "cat.enabled"
|
||||
keyCATBackend = "cat.backend" // "omnirig" (only one for now)
|
||||
@@ -151,8 +155,11 @@ type ModePreset struct {
|
||||
// ListsSettings holds the user-customisable dropdown lists used by the
|
||||
// entry form. Default values match common HF/VHF practice.
|
||||
type ListsSettings struct {
|
||||
Bands []string `json:"bands"`
|
||||
Modes []ModePreset `json:"modes"`
|
||||
Bands []string `json:"bands"`
|
||||
Modes []ModePreset `json:"modes"`
|
||||
RSTPhone []string `json:"rst_phone"` // RS reports for phone modes
|
||||
RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK
|
||||
RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT…
|
||||
}
|
||||
|
||||
var defaultBands = []string{
|
||||
@@ -171,6 +178,49 @@ var defaultModes = []ModePreset{
|
||||
{Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||||
}
|
||||
|
||||
// Default RST report lists, editable in Settings → Modes. Phone carries the
|
||||
// over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid;
|
||||
// digital the dB reports +30…-30.
|
||||
var defaultRSTPhone = buildPhoneRST()
|
||||
var defaultRSTCW = buildCWRST()
|
||||
var defaultRSTDigital = buildDigitalRST()
|
||||
|
||||
func buildPhoneRST() []string {
|
||||
out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"}
|
||||
for r := 5; r >= 1; r-- {
|
||||
for s := 9; s >= 1; s-- {
|
||||
out = append(out, fmt.Sprintf("%d%d", r, s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func buildCWRST() []string {
|
||||
var out []string
|
||||
for r := 5; r >= 1; r-- {
|
||||
for s := 9; s >= 1; s-- {
|
||||
for t := 9; t >= 1; t-- {
|
||||
out = append(out, fmt.Sprintf("%d%d%d", r, s, t))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func buildDigitalRST() []string {
|
||||
var out []string
|
||||
for db := 30; db >= -30; db-- {
|
||||
sign := "+"
|
||||
if db < 0 {
|
||||
sign = "-"
|
||||
}
|
||||
n := db
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s%02d", sign, n))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StationSettings holds the active operator profile. Used to stamp every
|
||||
// new QSO so we don't ask the user to retype it for each contact.
|
||||
// Multi-profile support (portable / SOTA …) will layer on top of this.
|
||||
@@ -214,7 +264,8 @@ type App struct {
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
||||
|
||||
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||
// close fires shutdown tasks (backup, future LoTW upload, ...) while
|
||||
@@ -316,7 +367,7 @@ func (a *App) refreshOperatorGrid() {
|
||||
// without making the lookup package import dxcc.
|
||||
type dxccAdapter struct{ m *dxcc.Manager }
|
||||
|
||||
func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) {
|
||||
func (a dxccAdapter) Resolve(call string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool) {
|
||||
if a.m == nil {
|
||||
return
|
||||
}
|
||||
@@ -324,7 +375,7 @@ func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz
|
||||
if !found || mm.Entity == nil {
|
||||
return
|
||||
}
|
||||
return mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
|
||||
return dxcc.EntityDXCC(mm.Entity.Name), mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
|
||||
}
|
||||
|
||||
func NewApp() *App { return &App{} }
|
||||
@@ -343,12 +394,29 @@ func (a *App) startup(ctx context.Context) {
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
return
|
||||
}
|
||||
a.dataDir = dataDir
|
||||
a.dbPath = filepath.Join(dataDir, "opslog.db")
|
||||
// One-shot rename for users coming from the HamLog era.
|
||||
if _, err := os.Stat(a.dbPath); os.IsNotExist(err) {
|
||||
oldDB := filepath.Join(dataDir, "hamlog.db")
|
||||
if _, err := os.Stat(oldDB); err == nil {
|
||||
_ = os.Rename(oldDB, a.dbPath)
|
||||
usingDefault := true
|
||||
// config.json (in the data dir) may point the database to a user-chosen
|
||||
// location — e.g. another drive or a synced folder, so it survives a
|
||||
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
||||
// before opening it.
|
||||
if custom := readDBPointer(dataDir); custom != "" {
|
||||
a.dbPath = custom
|
||||
usingDefault = false
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||
a.startupErr = "cannot create db folder: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
return
|
||||
}
|
||||
// One-shot rename for users coming from the HamLog era (default location only).
|
||||
if usingDefault {
|
||||
if _, err := os.Stat(a.dbPath); os.IsNotExist(err) {
|
||||
oldDB := filepath.Join(dataDir, "hamlog.db")
|
||||
if _, err := os.Stat(oldDB); err == nil {
|
||||
_ = os.Rename(oldDB, a.dbPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := applog.Init(dataDir); err != nil {
|
||||
@@ -632,6 +700,122 @@ func userDataDir() (string, error) {
|
||||
return newDir, nil
|
||||
}
|
||||
|
||||
// ── Database location (config.json pointer) ────────────────────────────
|
||||
|
||||
// dbPointer is the tiny bootstrap config stored in the data dir. It must
|
||||
// live outside the database because we read it to decide which DB to open.
|
||||
type dbPointer struct {
|
||||
DBPath string `json:"db_path"`
|
||||
}
|
||||
|
||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||
|
||||
// readDBPointer returns the user-chosen DB path, or "" for the default.
|
||||
func readDBPointer(dataDir string) string {
|
||||
b, err := os.ReadFile(dbPointerPath(dataDir))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var c dbPointer
|
||||
if json.Unmarshal(b, &c) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.DBPath)
|
||||
}
|
||||
|
||||
// writeDBPointer persists the chosen DB path ("" resets to default).
|
||||
func writeDBPointer(dataDir, path string) error {
|
||||
b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ")
|
||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||
}
|
||||
|
||||
// DatabaseSettings describes the active database file for the Settings UI.
|
||||
type DatabaseSettings struct {
|
||||
Path string `json:"path"`
|
||||
DefaultPath string `json:"default_path"`
|
||||
IsCustom bool `json:"is_custom"`
|
||||
}
|
||||
|
||||
// GetDatabaseSettings returns where the active database lives.
|
||||
func (a *App) GetDatabaseSettings() DatabaseSettings {
|
||||
def := filepath.Join(a.dataDir, "opslog.db")
|
||||
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
|
||||
}
|
||||
|
||||
// PickOpenDatabase opens a file dialog to choose an existing .db file.
|
||||
func (a *App) PickOpenDatabase() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Open an OpsLog database",
|
||||
DefaultDirectory: filepath.Dir(a.dbPath),
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
})
|
||||
}
|
||||
|
||||
// PickSaveDatabase opens a save dialog to choose where to put a copy.
|
||||
func (a *App) PickSaveDatabase() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
Title: "Save the OpsLog database to…",
|
||||
DefaultFilename: "opslog.db",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
})
|
||||
}
|
||||
|
||||
// OpenDatabase points OpsLog at an existing database file. Takes effect on
|
||||
// the next launch.
|
||||
func (a *App) OpenDatabase(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("no path given")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("database file not found: %w", err)
|
||||
}
|
||||
return writeDBPointer(a.dataDir, path)
|
||||
}
|
||||
|
||||
// MoveDatabase writes a clean copy of the current database to dest (which
|
||||
// must not exist yet) and switches OpsLog to it on the next launch. Uses
|
||||
// VACUUM INTO so the copy is consistent even with an open WAL.
|
||||
func (a *App) MoveDatabase(dest string) error {
|
||||
dest = strings.TrimSpace(dest)
|
||||
if dest == "" {
|
||||
return fmt.Errorf("no destination given")
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
if a.db == nil {
|
||||
return fmt.Errorf("database not open")
|
||||
}
|
||||
// VACUUM INTO takes a string literal; escape single quotes in the path.
|
||||
safe := strings.ReplaceAll(dest, "'", "''")
|
||||
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
|
||||
return fmt.Errorf("copy database: %w", err)
|
||||
}
|
||||
return writeDBPointer(a.dataDir, dest)
|
||||
}
|
||||
|
||||
// ResetDatabaseToDefault clears the custom location (back to the data dir).
|
||||
func (a *App) ResetDatabaseToDefault() error {
|
||||
return writeDBPointer(a.dataDir, "")
|
||||
}
|
||||
|
||||
// QuitApp closes OpsLog (used to apply a database change on next launch).
|
||||
func (a *App) QuitApp() {
|
||||
if a.ctx != nil {
|
||||
wruntime.Quit(a.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
||||
// Called at startup and after the user saves new credentials.
|
||||
//
|
||||
@@ -721,6 +905,16 @@ type StationInfoComputed struct {
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
// ListCountries returns the DXCC entity names for the Country picker, so the
|
||||
// user selects from a fixed list instead of typing (avoids typos). Empty
|
||||
// until cty.dat has loaded.
|
||||
func (a *App) ListCountries() []string {
|
||||
if a.dxcc == nil {
|
||||
return nil
|
||||
}
|
||||
return a.dxcc.EntityNames()
|
||||
}
|
||||
|
||||
// ComputeStationInfo resolves a station's structured metadata from the
|
||||
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
|
||||
// frontend calls this whenever Callsign or Grid changes in the Station
|
||||
@@ -1646,9 +1840,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
// Snapshot what's already confirmed so we can flag each incoming
|
||||
// confirmation as a NEW DXCC / band / slot.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx)
|
||||
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
|
||||
// that count for ARRL awards) so each incoming one is flagged NEW.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
||||
var items []ConfirmationItem
|
||||
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
@@ -1715,12 +1909,148 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
case extsvc.ServiceQRZ:
|
||||
emit("Fetching QRZ.com logbook…")
|
||||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
|
||||
if err != nil {
|
||||
emit("Fetch failed: " + err.Error())
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
adifText := fr.ADIF
|
||||
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
|
||||
if snip := strings.TrimSpace(adifText); snip != "" {
|
||||
if len(snip) > 300 {
|
||||
snip = snip[:300]
|
||||
}
|
||||
emit("ADIF head: " + snip)
|
||||
}
|
||||
keyIDs, _ := a.qso.DedupeKeyIDs(ctx)
|
||||
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
||||
// judged only against other QRZ confirmations.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
||||
// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
|
||||
// without a per-record DB read.
|
||||
alreadyQrz := map[int64]bool{}
|
||||
if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
|
||||
for rs.Next() {
|
||||
var id int64
|
||||
if rs.Scan(&id) == nil {
|
||||
alreadyQrz[id] = true
|
||||
}
|
||||
}
|
||||
rs.Close()
|
||||
}
|
||||
var items []ConfirmationItem
|
||||
parsed := 0
|
||||
allKeys := map[string]bool{} // union of field names seen, for diagnostics
|
||||
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
|
||||
// parser treats the stream as records.
|
||||
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
|
||||
parsed++
|
||||
for k := range rec {
|
||||
allKeys[k] = true
|
||||
}
|
||||
if !qrzRecordConfirmed(rec) {
|
||||
return nil
|
||||
}
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
total++
|
||||
date := rec["qrzcom_qso_download_date"]
|
||||
if date == "" {
|
||||
date = time.Now().UTC().Format("20060102")
|
||||
}
|
||||
a.enrichContactedFromCty(&q)
|
||||
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
|
||||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
|
||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||
id, found := keyIDs[key]
|
||||
switch {
|
||||
case found:
|
||||
if alreadyQrz[id] {
|
||||
emit(line + " ### ALREADY CONFIRMED ###")
|
||||
} else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil {
|
||||
alreadyQrz[id] = true
|
||||
matched++
|
||||
emit(line + " ### UPDATED ###")
|
||||
}
|
||||
case addNotFound:
|
||||
q.QRZComUploadStatus = "Y"
|
||||
q.QRZComDownloadStatus = "Y"
|
||||
q.QRZComDownloadDate = date
|
||||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||||
keyIDs[key] = newID
|
||||
added++
|
||||
emit(line + " ### ADDED ###")
|
||||
}
|
||||
default:
|
||||
emit(line + " ### NOT IN LOG ###")
|
||||
}
|
||||
// Result row + NEW flags.
|
||||
dxccNum := 0
|
||||
if q.DXCC != nil {
|
||||
dxccNum = *q.DXCC
|
||||
}
|
||||
it := ConfirmationItem{
|
||||
Callsign: q.Callsign,
|
||||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
})
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||||
}
|
||||
if perr != nil {
|
||||
emit("Parse error: " + perr.Error())
|
||||
}
|
||||
// Diagnostic: the union of every field name QRZ returned, so we can
|
||||
// pin the confirmation marker against real data.
|
||||
keys := make([]string, 0, len(allKeys))
|
||||
for k := range allKeys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||||
|
||||
default:
|
||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||
}
|
||||
done(matched+added, total)
|
||||
}
|
||||
|
||||
// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a
|
||||
// confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we
|
||||
// accept the likely candidates; the download's one-time field dump lets us
|
||||
// pin the exact field against real data and tighten this if needed.
|
||||
func qrzRecordConfirmed(rec adif.Record) bool {
|
||||
if strings.EqualFold(rec["qsl_rcvd"], "Y") {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
|
||||
return true
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) {
|
||||
case "C", "Y":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
|
||||
// from cty.dat (offline) — used when adding a not-found confirmation that
|
||||
// only carries call/band/mode/date.
|
||||
@@ -2444,12 +2774,30 @@ func (a *App) GetListsSettings() (ListsSettings, error) {
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.Modes)
|
||||
}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.RSTPhone)
|
||||
}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.RSTCW)
|
||||
}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.RSTDigital)
|
||||
}
|
||||
if len(out.Bands) == 0 {
|
||||
out.Bands = append([]string(nil), defaultBands...)
|
||||
}
|
||||
if len(out.Modes) == 0 {
|
||||
out.Modes = append([]ModePreset(nil), defaultModes...)
|
||||
}
|
||||
if len(out.RSTPhone) == 0 {
|
||||
out.RSTPhone = append([]string(nil), defaultRSTPhone...)
|
||||
}
|
||||
if len(out.RSTCW) == 0 {
|
||||
out.RSTCW = append([]string(nil), defaultRSTCW...)
|
||||
}
|
||||
if len(out.RSTDigital) == 0 {
|
||||
out.RSTDigital = append([]string(nil), defaultRSTDigital...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -2469,7 +2817,23 @@ func (a *App) SaveListsSettings(l ListsSettings) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyListsModes, string(m))
|
||||
if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range map[string][]string{
|
||||
keyListsRSTPhone: l.RSTPhone,
|
||||
keyListsRSTCW: l.RSTCW,
|
||||
keyListsRSTDigital: l.RSTDigital,
|
||||
} {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, k, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveStationSettings updates only the six "basic" fields on the active
|
||||
@@ -2624,6 +2988,28 @@ func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
|
||||
return pst.New(s.Host, s.Port), s, nil
|
||||
}
|
||||
|
||||
// RotatorHeading is the live antenna heading for the status bar.
|
||||
type RotatorHeading struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OK bool `json:"ok"`
|
||||
Azimuth int `json:"azimuth"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// GetRotatorHeading queries PstRotator for the current azimuth. Returns
|
||||
// Enabled=false when the rotator isn't configured. Polled by the status bar.
|
||||
func (a *App) GetRotatorHeading() RotatorHeading {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil || !s.Enabled {
|
||||
return RotatorHeading{Enabled: false}
|
||||
}
|
||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||
if herr != nil {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||
}
|
||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
||||
}
|
||||
|
||||
// RotatorGoTo points the antenna at the given azimuth (and optional
|
||||
// elevation if the rotator is configured for it).
|
||||
func (a *App) RotatorGoTo(az int, el int) error {
|
||||
@@ -2891,6 +3277,28 @@ func (a *App) SendClusterCommand(cmd string) error {
|
||||
return fmt.Errorf("no enabled cluster server to send to")
|
||||
}
|
||||
|
||||
// SendClusterSpot announces a DX spot on the **master** cluster (first
|
||||
// enabled server). Format is the universal DXSpider/AR-Cluster command
|
||||
// `DX <freq_khz> <call> <comment>`. The frequency is taken in kHz; call is
|
||||
// upper-cased; comment is optional (commonly the mode, e.g. "CW").
|
||||
func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error {
|
||||
call = strings.ToUpper(strings.TrimSpace(call))
|
||||
if call == "" {
|
||||
return fmt.Errorf("callsign required")
|
||||
}
|
||||
if freqKHz <= 0 {
|
||||
return fmt.Errorf("invalid frequency")
|
||||
}
|
||||
// Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0),
|
||||
// but keep sub-kHz precision when present (e.g. 10138.7).
|
||||
freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64)
|
||||
cmd := fmt.Sprintf("DX %s %s", freqStr, call)
|
||||
if c := strings.TrimSpace(comment); c != "" {
|
||||
cmd += " " + c
|
||||
}
|
||||
return a.SendClusterCommand(cmd)
|
||||
}
|
||||
|
||||
// GetClusterStatus returns a snapshot of every active session. Used by
|
||||
// the UI on mount and to hydrate after a `cluster:state` event.
|
||||
func (a *App) GetClusterStatus() []cluster.ServerStatus {
|
||||
|
||||
+389
-192
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X,
|
||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -14,24 +14,25 @@ import {
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||
RefreshCtyDat,
|
||||
RotatorGoTo, RotatorStop,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses,
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
GetCATSettings,
|
||||
OperatingDefaultForBand,
|
||||
LogUDPLoggedADIF,
|
||||
ListCountries,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import { QSLManagerModal } from '@/components/QSLManagerModal';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
@@ -39,6 +40,7 @@ import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
|
||||
type QSO = QSOForm;
|
||||
type ImportResult = adifModels.ImportResult;
|
||||
@@ -141,6 +144,38 @@ function shortCatError(err?: string): string {
|
||||
if (e.includes('coinitialize')) return 'COM error';
|
||||
return err.length > 24 ? err.slice(0, 22) + '…' : err;
|
||||
}
|
||||
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
|
||||
// every known allocation (used to auto-fill the band when the freq changes).
|
||||
function bandForMHz(mhz: number): string {
|
||||
if (!mhz || isNaN(mhz)) return '';
|
||||
const plan: [number, number, string][] = [
|
||||
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
||||
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
||||
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
||||
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
|
||||
];
|
||||
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
||||
return '';
|
||||
}
|
||||
|
||||
// rstCategory buckets a mode into the report family used for its RST list.
|
||||
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||
function rstCategory(mode: string): keyof RSTLists {
|
||||
const m = (mode || '').toUpperCase();
|
||||
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
|
||||
if (digital.includes(m)) return 'digital';
|
||||
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||
return 'phone';
|
||||
}
|
||||
// rstOptions returns the valid report choices for a mode from the user's
|
||||
// editable lists (Settings → Modes), with a tiny fallback before they load.
|
||||
function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||
const cat = rstCategory(mode);
|
||||
const l = lists[cat];
|
||||
if (l && l.length) return l;
|
||||
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||
}
|
||||
|
||||
function computePrefix(call: string): string {
|
||||
if (!call) return '';
|
||||
const c = call.trim().toUpperCase().split('/')[0];
|
||||
@@ -159,6 +194,8 @@ export default function App() {
|
||||
|
||||
// === Entry ===
|
||||
const [callsign, setCallsign] = useState('');
|
||||
// Ref to the callsign input so ESC can snap focus back to it.
|
||||
const callsignRef = useRef<HTMLInputElement>(null);
|
||||
// QSO start time — frozen when the operator starts typing the callsign,
|
||||
// logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
|
||||
const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
|
||||
@@ -228,6 +265,10 @@ export default function App() {
|
||||
const [freqMhz, setFreqMhz] = useState('');
|
||||
// RX freq for split — only set/shown when the rig is in split mode.
|
||||
const [rxFreqMhz, setRxFreqMhz] = useState('');
|
||||
// RX band — follows the TX band by default; only differs for cross-band work.
|
||||
const [bandRx, setBandRx] = useState('20m');
|
||||
const [countries, setCountries] = useState<string[]>([]);
|
||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||
const [rstSent, setRstSent] = useState('59');
|
||||
const [rstRcvd, setRstRcvd] = useState('59');
|
||||
const [grid, setGrid] = useState('');
|
||||
@@ -248,6 +289,7 @@ export default function App() {
|
||||
|
||||
// CAT — receives live rig state via Wails events.
|
||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||
// in Preferences > Hardware > CAT interface.
|
||||
@@ -360,11 +402,24 @@ export default function App() {
|
||||
const [qsos, setQsos] = useState<QSO[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [error, setError] = useState('');
|
||||
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
||||
// like "spot sent" where a blocking error banner would be overkill.
|
||||
const [toast, setToast] = useState('');
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
|
||||
}, []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filterCallsign, setFilterCallsign] = useState('');
|
||||
const [filterBand, setFilterBand] = useState('');
|
||||
const [filterMode, setFilterMode] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('recent');
|
||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||
function closeQslTab() {
|
||||
setQslTabOpen(false);
|
||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||
}
|
||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||
@@ -410,6 +465,8 @@ export default function App() {
|
||||
retries?: number;
|
||||
};
|
||||
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
|
||||
// "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
|
||||
const [showSpotModal, setShowSpotModal] = useState(false);
|
||||
const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
|
||||
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
||||
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
||||
@@ -449,7 +506,6 @@ export default function App() {
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [showQSLManager, setShowQSLManager] = useState(false);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||
|
||||
@@ -518,6 +574,44 @@ export default function App() {
|
||||
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
|
||||
}, [refresh]);
|
||||
|
||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||
// rotator is disabled (the backend just reads settings and returns).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 3000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||
useEffect(() => { setBandRx(band); }, [band]);
|
||||
|
||||
// RX freq mirrors TX freq on every TX change (unless the rig is in split,
|
||||
// where the RX freq is genuinely different). It stays editable by hand:
|
||||
// a manual RX edit sticks until the next TX-freq change re-syncs it.
|
||||
useEffect(() => {
|
||||
if (!catState.split) setRxFreqMhz(freqMhz);
|
||||
}, [freqMhz, catState.split]);
|
||||
|
||||
// Load the DXCC country list for the Country picker. cty.dat loads a few
|
||||
// seconds after startup, so retry until it's available.
|
||||
useEffect(() => {
|
||||
let tries = 0;
|
||||
let timer = 0;
|
||||
const load = async () => {
|
||||
try {
|
||||
const c = await ListCountries();
|
||||
if (c && c.length) { setCountries(c); return; }
|
||||
} catch {}
|
||||
if (tries++ < 15) timer = window.setTimeout(load, 2000);
|
||||
};
|
||||
load();
|
||||
return () => { if (timer) window.clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
const loadStation = useCallback(async () => {
|
||||
try { setStation(await GetStationSettings()); } catch {}
|
||||
}, []);
|
||||
@@ -530,6 +624,7 @@ export default function App() {
|
||||
const loadLists = useCallback(async () => {
|
||||
try {
|
||||
const l: ListsSettings = await GetListsSettings();
|
||||
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
|
||||
if (l.bands && l.bands.length) setBands(l.bands);
|
||||
if (l.modes && l.modes.length) {
|
||||
setModePresets(l.modes);
|
||||
@@ -581,14 +676,14 @@ export default function App() {
|
||||
if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
|
||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||
}
|
||||
// RX freq (split only): backend follows ADIF — freq_hz = TX,
|
||||
// freq_rx_hz = RX. Only set when the rig is in split, otherwise the
|
||||
// field would duplicate TX for no reason. The freq lock covers both.
|
||||
// RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
|
||||
// In split we take the rig's real RX freq; otherwise RX mirrors TX
|
||||
// (the user can still override it by hand). The freq lock covers both.
|
||||
if (!lk.freq) {
|
||||
if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
|
||||
setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
|
||||
} else {
|
||||
setRxFreqMhz('');
|
||||
} else if (s.freq_hz && s.freq_hz > 0) {
|
||||
setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||
}
|
||||
}
|
||||
if (!lk.band && s.band) setBand(s.band);
|
||||
@@ -736,7 +831,7 @@ export default function App() {
|
||||
callsign: callsign.trim().toUpperCase(),
|
||||
qso_date: start.toISOString(),
|
||||
qso_date_off: end.toISOString(),
|
||||
band, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
|
||||
band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
|
||||
rst_sent: rstSent, rst_rcvd: rstRcvd,
|
||||
grid: grid.trim().toUpperCase(),
|
||||
name, qth, country, comment, notes: note,
|
||||
@@ -981,9 +1076,8 @@ export default function App() {
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
|
||||
{ type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true },
|
||||
{ type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true },
|
||||
{ type: 'item', label: 'Rotator', action: 'tools.rotator', disabled: true },
|
||||
{ type: 'item', label: 'CAT interface…', action: 'tools.cat' },
|
||||
{ type: 'item', label: 'Rotator…', action: 'tools.rotator' },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
@@ -1004,8 +1098,10 @@ export default function App() {
|
||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setShowQSLManager(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
|
||||
case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break;
|
||||
case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
}
|
||||
}
|
||||
@@ -1097,9 +1193,20 @@ export default function App() {
|
||||
{catState.split && (
|
||||
<Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
|
||||
)}
|
||||
{/* Band & mode removed here — shown in the QSO entry strip below. */}
|
||||
{catState.enabled && catState.backend === 'omnirig' && (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-sans font-semibold">
|
||||
{[1, 2].map((n) => {
|
||||
const active = (catState.rig_num || 1) === n;
|
||||
return (
|
||||
<button key={n} type="button" onClick={() => SwitchCATRig(n).catch(() => {})}
|
||||
className={cn('px-2 py-0.5 transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
|
||||
title={`Use OmniRig Rig ${n}`}>R{n}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-px h-4 bg-border mx-2" />
|
||||
<Badge variant="accent" className="font-mono">{band}</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono" variant="outline">{mode}</Badge>
|
||||
{/* Bearing controls — three separate buttons so SP and LP are
|
||||
both directly clickable, plus an always-visible Stop. The
|
||||
old Shift/Ctrl shortcuts were not discoverable enough. */}
|
||||
@@ -1151,51 +1258,6 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{catState.enabled && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold font-sans ml-2 border',
|
||||
catState.connected
|
||||
? 'bg-emerald-100 text-emerald-800 border-emerald-300'
|
||||
: 'bg-rose-100 text-rose-800 border-rose-300',
|
||||
)}
|
||||
title={
|
||||
catState.connected
|
||||
? `CAT: ${catState.rig || catState.backend || 'connected'}`
|
||||
: catState.error || 'CAT offline'
|
||||
}
|
||||
>
|
||||
<RadioTower className={cn('size-3', catState.connected && 'animate-pulse')} />
|
||||
{catState.connected
|
||||
? (catState.rig || 'CAT')
|
||||
: (shortCatError(catState.error) || 'CAT off')}
|
||||
</span>
|
||||
{catState.backend === 'omnirig' && (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-[10px] font-sans font-semibold">
|
||||
{[1, 2].map((n) => {
|
||||
const active = (catState.rig_num || 1) === n;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => SwitchCATRig(n).catch(() => {})}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
title={`Use OmniRig Rig ${n}`}
|
||||
>
|
||||
R{n}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
@@ -1251,12 +1313,23 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transient success toast (bottom-right). */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<Satellite className="size-4 shrink-0" />
|
||||
<span>{toast}</span>
|
||||
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== ENTRY STRIP =====
|
||||
Enter from any <input> inside the strip logs the QSO. Radix Selects
|
||||
render as <button> elements and are ignored by this handler — they
|
||||
keep their own keyboard behaviour. */}
|
||||
<div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
|
||||
<section
|
||||
className="flex gap-2 items-end flex-wrap px-3 py-2 bg-card border-b border-border shadow-sm shrink-0"
|
||||
className={cn('flex gap-2 items-end flex-wrap content-start px-3 py-2 bg-card shadow-sm border-border',
|
||||
compact ? 'border-b shrink-0' : 'flex-1 min-w-[560px] max-w-[920px] border rounded-lg')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
||||
e.preventDefault();
|
||||
@@ -1264,9 +1337,12 @@ export default function App() {
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resetEntry();
|
||||
// Snap focus back to the callsign field, ready for the next QSO.
|
||||
callsignRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* ── Row 1: Callsign + RST ── */}
|
||||
<div className="flex flex-col w-40">
|
||||
<Label className="mb-1 flex items-center gap-2 h-3.5">
|
||||
Callsign
|
||||
@@ -1294,60 +1370,25 @@ export default function App() {
|
||||
{!lookupBusy && !lookupResult && lookupError && (
|
||||
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
|
||||
)}
|
||||
{/* Contacted entity flag (from its DXCC number). */}
|
||||
{flagURL(details.dxcc) && (
|
||||
<img src={flagURL(details.dxcc)} alt="" title={country}
|
||||
className="h-3.5 ml-auto rounded-[2px] border border-border/50 shadow-sm"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
ref={callsignRef}
|
||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||
value={callsign}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
|
||||
<Select value={band} onValueChange={onBandUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
|
||||
<Select value={mode} onValueChange={onModeUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">
|
||||
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" />
|
||||
</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
className="font-mono"
|
||||
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
|
||||
placeholder="14.250"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); }}
|
||||
/>
|
||||
</div>
|
||||
{catState.split && (
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 text-rose-600">RX Freq (MHz)</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
||||
placeholder="14.255"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); }}
|
||||
className="font-mono bg-rose-50/40 border-rose-200 focus:bg-card"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
|
||||
<Input value={rstSent} onChange={(e) => { setRstSent(e.target.value); rstUserEditedRef.current = true; }} />
|
||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
|
||||
<Input value={rstRcvd} onChange={(e) => { setRstRcvd(e.target.value); rstUserEditedRef.current = true; }} />
|
||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">
|
||||
@@ -1405,61 +1446,102 @@ export default function App() {
|
||||
className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')}
|
||||
/>
|
||||
</div>
|
||||
{/* Optional ID/location fields — hidden in compact mode. */}
|
||||
|
||||
{/* ── Row 2: Operator name + QTH + Grid + Country + zones (hidden in compact) ── */}
|
||||
{!compact && <>
|
||||
<div className="flex flex-col w-24"><Label className="mb-1 h-3.5">Grid</Label>
|
||||
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Name</Label>
|
||||
<div className="basis-full h-0" aria-hidden />
|
||||
<div className="flex flex-col w-48"><Label className="mb-1 h-3.5">Name</Label>
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">QTH</Label>
|
||||
<div className="flex flex-col w-36"><Label className="mb-1 h-3.5">QTH</Label>
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Country</Label>
|
||||
<Input value={country} onChange={(e) => { setCountry(e.target.value); markEdited('country'); }} />
|
||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
|
||||
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
{/* Numeric DXCC metadata + short-path azimuth — surfaced in the
|
||||
main strip per user request (LP + distances stay in F2). */}
|
||||
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">DXCC #</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="font-mono"
|
||||
value={details.dxcc ?? ''}
|
||||
onChange={(e) => updateDetails({ dxcc: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
|
||||
placeholder="—"
|
||||
/>
|
||||
<div className="flex flex-col w-40">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1.5">
|
||||
Country
|
||||
{flagURL(details.dxcc) && (
|
||||
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</Label>
|
||||
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">CQ</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="font-mono"
|
||||
value={details.cqz ?? ''}
|
||||
onChange={(e) => updateDetails({ cqz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
|
||||
placeholder="—"
|
||||
/>
|
||||
{/* DXCC # and Continent are derived from the callsign — read-only.
|
||||
CQ/ITU stay editable but as plain text (no number spinners).
|
||||
Kept compact (Log4OM-style) — just wide enough for their digits. */}
|
||||
<div className="flex flex-col w-11"><Label className="mb-1 h-3.5">DXCC</Label>
|
||||
<Input readOnly tabIndex={-1} className="font-mono bg-muted/40 cursor-default text-center px-1 text-xs"
|
||||
value={details.dxcc ?? ''} placeholder="—" />
|
||||
</div>
|
||||
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">ITU</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="font-mono"
|
||||
value={details.ituz ?? ''}
|
||||
onChange={(e) => updateDetails({ ituz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
|
||||
placeholder="—"
|
||||
/>
|
||||
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">CQ</Label>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.cqz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-14"><Label className="mb-1 h-3.5">Cont</Label>
|
||||
<Input
|
||||
className="font-mono uppercase"
|
||||
value={details.cont}
|
||||
onChange={(e) => updateDetails({ cont: e.target.value.toUpperCase() })}
|
||||
placeholder="—"
|
||||
maxLength={2}
|
||||
/>
|
||||
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">ITU</Label>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.ituz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">Cont</Label>
|
||||
<Input readOnly tabIndex={-1} className="font-mono uppercase bg-muted/40 cursor-default text-center px-1 text-xs"
|
||||
value={details.cont} placeholder="—" />
|
||||
</div>
|
||||
</>}
|
||||
{/* Comment stays visible in compact mode — handy for quick contest/
|
||||
portable annotations alongside the basic frequency info. */}
|
||||
|
||||
{/* ── Row 3: Freq + Band + Mode + Band RX + RX Freq ── */}
|
||||
<div className="basis-full h-0" aria-hidden />
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">
|
||||
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" />
|
||||
</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
className="font-mono"
|
||||
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
|
||||
placeholder="14.250"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
|
||||
<Select value={band} onValueChange={onBandUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
|
||||
<Select value={mode} onValueChange={onModeUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5">Band RX</Label>
|
||||
<Select value={bandRx} onValueChange={setBandRx}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>RX Freq (MHz)</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
||||
placeholder="14.255"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
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')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Row 4: Comment + Note ── */}
|
||||
<div className="basis-full h-0" aria-hidden />
|
||||
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Comment</Label>
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
@@ -1468,60 +1550,74 @@ export default function App() {
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col ml-auto">
|
||||
<Label className="mb-1 h-3.5"> </Label>
|
||||
<Button onClick={save} disabled={saving} className="h-8">
|
||||
<Send className="size-3.5" />
|
||||
{saving ? '…' : 'Log QSO'}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{/* Send DX spot — only when a cluster is connected. Pre-fills the
|
||||
dialog from the current entry (or the last logged QSO). */}
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowSpotModal(true)}
|
||||
className="h-8"
|
||||
title="Send a DX spot to the master cluster"
|
||||
>
|
||||
<Satellite className="size-3.5" />
|
||||
Spot
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={save} disabled={saving} className="h-8">
|
||||
<Send className="size-3.5" />
|
||||
{saving ? '…' : 'Log QSO'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* In compact mode the entry strip is the whole app — hide everything
|
||||
else and let the user re-expand with the topbar toggle. */}
|
||||
{compact ? null : <>
|
||||
{/* ===== BAND/SLOT GRID ===== */}
|
||||
{/* QRZ profile picture sits next to the matrix when the user has
|
||||
opted in (Settings → Lookup → Show QRZ profile pictures). The
|
||||
backend returns image_url="" when the toggle is off, so we
|
||||
don't need to re-check the setting here. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<BandSlotGrid wb={wb} busy={wbBusy} currentBand={band} currentMode={mode} />
|
||||
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
|
||||
tabs, then reserved free space. Hidden in compact mode. */}
|
||||
{!compact && (
|
||||
<div className="w-[560px] shrink-0 min-h-0 flex flex-col">
|
||||
<DetailsPanel
|
||||
callsign={callsign}
|
||||
prefix={prefix}
|
||||
operatorGrid={station.my_grid}
|
||||
remoteGrid={grid}
|
||||
details={details}
|
||||
onChange={updateDetails}
|
||||
wb={wb}
|
||||
wbBusy={wbBusy}
|
||||
band={band}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
{lookupResult?.image_url && (
|
||||
<a
|
||||
href={lookupResult.image_url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
OpenExternalURL(lookupResult.image_url!).catch((err) => setError(String(err?.message ?? err)));
|
||||
}}
|
||||
)}
|
||||
{/* Reserved free space to the right — shows the QRZ profile photo large
|
||||
so it's actually legible. Click opens the full-size image on QRZ. */}
|
||||
{!compact && lookupResult?.image_url && (
|
||||
<div className="flex-1 min-w-0 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lookupResult.image_url && OpenExternalURL(lookupResult.image_url).catch((err) => setError(String(err?.message ?? err)))}
|
||||
className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
|
||||
title="Open full-size on QRZ.com"
|
||||
className="block shrink-0 rounded border border-border overflow-hidden hover:border-primary/60 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={lookupResult.image_url}
|
||||
alt={`${callsign} profile`}
|
||||
className="block w-[160px] h-[120px] object-cover bg-muted/30"
|
||||
alt="profile"
|
||||
className="block max-h-[180px] max-w-full w-auto object-contain"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== F2-F5 DETAILS ===== */}
|
||||
<DetailsPanel
|
||||
callsign={callsign}
|
||||
prefix={prefix}
|
||||
operatorGrid={station.my_grid}
|
||||
remoteGrid={grid}
|
||||
details={details}
|
||||
onChange={updateDetails}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* /entry + aside row */}
|
||||
|
||||
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
|
||||
{compact ? null : <>
|
||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', showBandMap ? 'grid-cols-[1fr_260px]' : 'grid-cols-[1fr]')}>
|
||||
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
||||
@@ -1540,6 +1636,21 @@ export default function App() {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||
<TabsTrigger value="propagation">Propagation</TabsTrigger>
|
||||
{qslTabOpen && (
|
||||
<TabsTrigger value="qsl" className="gap-1.5">
|
||||
QSL Manager
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close QSL Manager"
|
||||
title="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(); closeQslTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
@@ -1949,6 +2060,15 @@ export default function App() {
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
tab's × . forceMount keeps its state (and a running download
|
||||
updating) while you work on other tabs. */}
|
||||
{qslTabOpen && (
|
||||
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<QSLManagerPanel />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{(['main','awards','propagation'] as const).map((t) => (
|
||||
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<Hash className="size-10 opacity-30" />
|
||||
@@ -1985,9 +2105,87 @@ export default function App() {
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* ===== STATUS BAR ===== */}
|
||||
{!compact && (() => {
|
||||
const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected');
|
||||
const catUp = catState.enabled && catState.connected;
|
||||
const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors',
|
||||
disabled ? 'opacity-50 cursor-default border-transparent'
|
||||
: 'border-border hover:bg-muted cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', on ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
|
||||
</span>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
|
||||
<Chip
|
||||
on={catUp}
|
||||
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
|
||||
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
|
||||
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
||||
/>
|
||||
<Chip
|
||||
on={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
label={rotatorHeading.enabled && rotatorHeading.ok ? `Rotator ${rotatorHeading.azimuth}°` : 'Rotator'}
|
||||
title={rotatorHeading.enabled ? (rotatorHeading.ok ? `Antenna heading ${rotatorHeading.azimuth}°` : 'Rotator: no position') : 'Rotator disabled'}
|
||||
disabled={!rotatorHeading.enabled}
|
||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
</footer>
|
||||
);
|
||||
})()}
|
||||
|
||||
{editingQSO && (
|
||||
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} />
|
||||
)}
|
||||
|
||||
<SendSpotModal
|
||||
open={showSpotModal}
|
||||
onClose={() => setShowSpotModal(false)}
|
||||
// Callsign: the QSO-entry call, else the last logged QSO.
|
||||
defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
|
||||
// Freq: the entry TX freq (kHz), else the last logged QSO's.
|
||||
defaultFreqKHz={
|
||||
parseFloat(freqMhz) > 0
|
||||
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
||||
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
||||
}
|
||||
defaultMode={mode || qsos[0]?.mode || ''}
|
||||
targetName={
|
||||
clusterServers
|
||||
.filter((s) => s.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name
|
||||
}
|
||||
recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({
|
||||
callsign: q.callsign,
|
||||
freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0,
|
||||
mode: q.mode ?? '',
|
||||
band: q.band,
|
||||
}))}
|
||||
onSend={async (call, freqKHz, comment) => {
|
||||
await SendClusterSpot(call, freqKHz, comment);
|
||||
const target = clusterServers
|
||||
.filter((s) => s.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name;
|
||||
showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showSettings && (
|
||||
<SettingsModal
|
||||
initialSection={settingsSection}
|
||||
@@ -1995,7 +2193,6 @@ export default function App() {
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }}
|
||||
/>
|
||||
)}
|
||||
<QSLManagerModal open={showQSLManager} onClose={() => setShowQSLManager(false)} />
|
||||
|
||||
{deletingQSO && (
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -65,6 +65,28 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn('size-2 rounded-full', cls)} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable label for a spot status — used in the pill hover tooltip
|
||||
// so the operator can see WHY a spot is coloured the way it is.
|
||||
function statusLabel(s: string): string {
|
||||
switch (s) {
|
||||
case 'new': return 'NEW DXCC (entity never worked)';
|
||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
||||
case 'worked': return 'Worked (this band + mode already in log)';
|
||||
default: return 'Entity not resolved';
|
||||
}
|
||||
}
|
||||
|
||||
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
||||
// pill = full pill background+text+border
|
||||
// bar = thick left accent inside the pill
|
||||
@@ -114,6 +136,13 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
|
||||
const LABEL_W = 200;
|
||||
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
||||
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
|
||||
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
|
||||
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
|
||||
// digital spots are always shown in full. When more than this FT spots are in
|
||||
// band we keep the most useful (new entities first, worked last; ties broken
|
||||
// by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||
const range = BAND_RANGES[band];
|
||||
@@ -146,19 +175,53 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
// larger). When more labels stack than fit in the band's natural pixel
|
||||
// span, totalH grows so scrolling reveals them.
|
||||
type Placed = { spot: Spot; freqY: number; labelY: number };
|
||||
const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => {
|
||||
const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
|
||||
// innerH is the band's stretched pixel span; total adds top+bottom
|
||||
// padding so the edge freq labels aren't clipped at y=0 / y=H.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD };
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
|
||||
const seen = new Set<string>();
|
||||
const filtered: Spot[] = [];
|
||||
const inBand: Spot[] = [];
|
||||
for (const s of spots) {
|
||||
if (s.freq_khz < lo || s.freq_khz > hi) continue;
|
||||
if (seen.has(s.dx_call)) continue;
|
||||
seen.add(s.dx_call);
|
||||
filtered.push(s);
|
||||
inBand.push(s);
|
||||
}
|
||||
|
||||
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
|
||||
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
|
||||
// always shown in full.
|
||||
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
|
||||
const ftSpots = inBand.filter(isFlood);
|
||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||
|
||||
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
|
||||
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
||||
const rank = (s: Spot) => {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
switch (spotStatus[k]?.status ?? '') {
|
||||
case 'new': return 0;
|
||||
case 'new-band': return 1;
|
||||
case 'new-slot': return 2;
|
||||
case 'worked': return 4;
|
||||
default: return 3;
|
||||
}
|
||||
};
|
||||
let keptFt = ftSpots;
|
||||
let hiddenCount = 0;
|
||||
if (ftSpots.length > MAX_VISIBLE_SPOTS) {
|
||||
const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
|
||||
keptFt = [...ftSpots]
|
||||
.sort((a, b) => {
|
||||
const r = rank(a) - rank(b);
|
||||
if (r !== 0) return r;
|
||||
return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
|
||||
})
|
||||
.slice(0, MAX_VISIBLE_SPOTS);
|
||||
hiddenCount = ftSpots.length - keptFt.length;
|
||||
}
|
||||
const filtered = [...otherSpots, ...keptFt];
|
||||
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||
|
||||
// Desired pill-CENTRE Y for each spot = its true frequency's Y.
|
||||
@@ -206,14 +269,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
return {
|
||||
placed: out,
|
||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||
hidden: hiddenCount,
|
||||
};
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH]);
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
|
||||
|
||||
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
||||
|
||||
// Auto-centre on the rig frequency when the map opens or the band changes
|
||||
// (once per band, so it doesn't fight the user's manual scrolling). Waits
|
||||
// for the scroller height to be measured and a valid in-band rig freq.
|
||||
const centeredForRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
if (centeredForRef.current === band) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
centeredForRef.current = band;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
@@ -375,7 +456,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
{/* Pills absolutely positioned at their (anti-overlapped) Y */}
|
||||
{placed.map((p, i) => {
|
||||
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
|
||||
const st = spotStatus[k]?.status ?? '';
|
||||
const entry = spotStatus[k];
|
||||
const st = entry?.status ?? '';
|
||||
const style = statusStyle(st);
|
||||
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
|
||||
return (
|
||||
@@ -389,7 +471,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
style.pill,
|
||||
)}
|
||||
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
>
|
||||
{/* Status accent strip on the left */}
|
||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||
@@ -406,8 +488,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
<LegendDot cls="bg-rose-400" label="New DXCC" />
|
||||
<LegendDot cls="bg-amber-400" label="New band" />
|
||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
||||
</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">
|
||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronUp, Construction } from 'lucide-react';
|
||||
import { Construction } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
|
||||
export interface DetailsState {
|
||||
state: string;
|
||||
@@ -45,9 +46,16 @@ interface Props {
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||
wb?: any;
|
||||
wbBusy?: boolean;
|
||||
band: string;
|
||||
mode: string;
|
||||
imageUrl?: string;
|
||||
onOpenImage?: () => void;
|
||||
}
|
||||
|
||||
type TabName = 'info' | 'awards' | 'my' | 'extended';
|
||||
type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
|
||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
|
||||
@@ -67,8 +75,8 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) {
|
||||
const [open, setOpen] = useState<TabName | null>(null);
|
||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
|
||||
const [open, setOpen] = useState<TabName>('stats');
|
||||
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
@@ -79,7 +87,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); }
|
||||
function toggle(t: TabName) { setOpen(t); }
|
||||
|
||||
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||
function setSatellite(on: boolean) {
|
||||
@@ -94,6 +102,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
}
|
||||
|
||||
const tabs: { key: TabName; label: string }[] = [
|
||||
{ key: 'stats', label: 'Stats (F1)' },
|
||||
{ key: 'info', label: 'Info (F2)' },
|
||||
{ key: 'awards', label: 'Awards (F3)' },
|
||||
{ key: 'my', label: 'My (F4)' },
|
||||
@@ -101,8 +110,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-card shrink-0">
|
||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border">
|
||||
<section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
@@ -117,17 +126,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{open && (
|
||||
<button
|
||||
onClick={() => setOpen(null)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
|
||||
title="Close"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="overflow-y-auto min-h-0">
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="State / pref">
|
||||
@@ -251,6 +258,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
@@ -28,7 +25,6 @@ const SERVICES = [
|
||||
{ v: 'lotw', label: 'LoTW' },
|
||||
];
|
||||
|
||||
// Sent-status filter values. Empty string = blank/none.
|
||||
const SENT_STATUSES = [
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'N', label: 'No' },
|
||||
@@ -46,7 +42,9 @@ function fmtDate(iso: string): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel() {
|
||||
const [service, setService] = useState('lotw');
|
||||
const [sent, setSent] = useState('R');
|
||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||
@@ -55,22 +53,20 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
const [error, setError] = useState('');
|
||||
const [addNotFound, setAddNotFound] = useState(false);
|
||||
|
||||
// 'upload' shows the Select-required search results; 'confirmations' shows
|
||||
// the rows returned by a Download.
|
||||
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
|
||||
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
||||
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
|
||||
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [logTitle, setLogTitle] = useState('');
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
const [showLog, setShowLog] = useState(false);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [uploadDone, setUploadDone] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
|
||||
useEffect(() => {
|
||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||
setUploadDone(true);
|
||||
setBusy(false);
|
||||
});
|
||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||
setConfirmations((list ?? []) as Confirmation[]);
|
||||
@@ -81,16 +77,28 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
|
||||
const selectedCount = selected.size;
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
async function selectRequired() {
|
||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||
switch (confFilter) {
|
||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||
case 'dxcc': return c.new_dxcc;
|
||||
case 'band': return c.new_band;
|
||||
case 'slot': return c.new_slot;
|
||||
default: return true;
|
||||
}
|
||||
}), [confirmations, confFilter]);
|
||||
|
||||
const selectRequired = useCallback(async () => {
|
||||
setSearching(true);
|
||||
setError('');
|
||||
try {
|
||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||
const list = (r ?? []) as UploadRow[];
|
||||
setRows(list);
|
||||
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
|
||||
setSelected(new Set(list.map((x) => x.id)));
|
||||
setViewMode('upload');
|
||||
setShowLog(false);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
setRows([]);
|
||||
@@ -98,7 +106,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
}, [service, sent]);
|
||||
|
||||
function toggle(id: number) {
|
||||
setSelected((s) => {
|
||||
@@ -114,209 +122,170 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
async function upload() {
|
||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
||||
if (ids.length === 0) return;
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogAction('upload');
|
||||
setLogTitle('Uploading to ' + serviceLabel);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await UploadQSOsManual(service, ids);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
|
||||
setUploadDone(true);
|
||||
}
|
||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||
try { await UploadQSOsManual(service, ids); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
async function download() {
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogAction('download');
|
||||
setLogTitle('Downloading confirmations from ' + serviceLabel);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await DownloadConfirmations(service, addNotFound);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
|
||||
setUploadDone(true);
|
||||
}
|
||||
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||
try { await DownloadConfirmations(service, addNotFound); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
function closeLog() {
|
||||
setLogOpen(false);
|
||||
// After an upload, refresh the search so uploaded QSOs drop out of the
|
||||
// filter. After a download, leave the confirmations list on screen.
|
||||
function viewResults() {
|
||||
setShowLog(false);
|
||||
if (logAction === 'upload') selectRequired();
|
||||
}
|
||||
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>QSL Manager</DialogTitle>
|
||||
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewMode === 'confirmations'
|
||||
? `${confirmations.length} confirmation(s) received`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
</span>
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
{!showLog && viewMode === 'confirmations' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
||||
<Select value={confFilter} onValueChange={setConfFilter}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="new">New (any)</SelectItem>
|
||||
<SelectItem value="dxcc">New DXCC</SelectItem>
|
||||
<SelectItem value="band">New band</SelectItem>
|
||||
<SelectItem value="slot">New slot</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{logLines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
||||
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewMode === 'confirmations'
|
||||
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results grid */}
|
||||
<div className="overflow-auto px-4 py-2 min-h-[200px]">
|
||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||
{/* Content: log OR results grid */}
|
||||
<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>}
|
||||
|
||||
{viewMode === 'confirmations' ? (
|
||||
confirmations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">No new confirmations.</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th>
|
||||
<th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th>
|
||||
<th className="py-1.5 px-2">New?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{confirmations.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||
<td className="py-1 px-2">{c.band}</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">
|
||||
{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">NEW DXCC</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">NEW BAND</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">NEW SLOT</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
Pick a service + sent status, then “Select required”.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th>
|
||||
<th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th>
|
||||
<th className="py-1.5 px-2">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => toggle(r.id)}
|
||||
>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||
<td className="py-1 px-2">{r.band}</td>
|
||||
<td className="py-1 px-2">{r.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={download} title="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" />
|
||||
Download confirmations
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
Add not-found
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
|
||||
<UploadCloud className="size-3.5" />
|
||||
Upload {selectedCount} to {serviceLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Upload progress / log window */}
|
||||
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{logTitle || 'Working…'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Progress log.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
|
||||
{showLog ? (
|
||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||
{logLines.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||
) : logLines.map((l, i) => (
|
||||
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div>
|
||||
<div key={i} className={cn(
|
||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
||||
: 'text-foreground/90')}>{l}</div>
|
||||
))}
|
||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working…</div>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
|
||||
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading…</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : viewMode === 'confirmations' ? (
|
||||
shownConfs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shownConfs.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||
<td className="py-1 px-2">{c.band}</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">
|
||||
{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">NEW DXCC</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">NEW BAND</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">NEW SLOT</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => toggle(r.id)}>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||
<td className="py-1 px-2">{r.band}</td>
|
||||
<td className="py-1 px-2">{r.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
Add not-found
|
||||
</label>
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -104,11 +105,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
||||
const [localErr, setLocalErr] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
||||
// the draft — handy after correcting the callsign. Only overwrites the
|
||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||
async function fetchLookup() {
|
||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||
if (!call) { setLocalErr('Callsign required'); return; }
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
const r: any = await LookupCallsign(call);
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: r.name ?? d.name,
|
||||
qth: r.qth ?? d.qth,
|
||||
address: r.address ?? (d as any).address,
|
||||
email: r.email ?? (d as any).email,
|
||||
country: r.country ?? d.country,
|
||||
grid: r.grid ?? d.grid,
|
||||
state: r.state ?? d.state,
|
||||
cnty: r.cnty ?? d.cnty,
|
||||
cont: r.cont ?? d.cont,
|
||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
||||
dxcc: r.dxcc || d.dxcc,
|
||||
cqz: r.cqz || d.cqz,
|
||||
ituz: r.ituz || d.ituz,
|
||||
lat: r.lat || d.lat,
|
||||
lon: r.lon || d.lon,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
||||
} finally {
|
||||
setLooking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
||||
setSaving(true);
|
||||
@@ -200,8 +237,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
<TabsContent value="basic" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Callsign" span={6}>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking}
|
||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…">
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
|
||||
Fetch
|
||||
</Button>
|
||||
</div>
|
||||
</F>
|
||||
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
|
||||
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
|
||||
@@ -232,6 +276,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
|
||||
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
|
||||
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Satellite, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface RecentSpotQSO {
|
||||
callsign: string;
|
||||
freqKHz: number;
|
||||
mode: string;
|
||||
band?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
// Pre-fill values: callsign from the QSO entry (or last logged), the
|
||||
// current TX freq in kHz, and the current mode (goes into the comment).
|
||||
defaultCall: string;
|
||||
defaultFreqKHz: number;
|
||||
defaultMode: string;
|
||||
// Master cluster name, shown so the user knows where the spot goes.
|
||||
targetName?: string;
|
||||
recent: RecentSpotQSO[];
|
||||
onSend: (call: string, freqKHz: number, comment: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on
|
||||
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||
// contact into the form.
|
||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
||||
const [call, setCall] = useState('');
|
||||
const [freqKHz, setFreqKHz] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [ok, setOk] = useState(false);
|
||||
const callRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// (Re)initialise the form each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCall((defaultCall || '').toUpperCase());
|
||||
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
||||
setMessage(defaultMode || '');
|
||||
setError('');
|
||||
setOk(false);
|
||||
// Focus the freq if the call is already known, else the call.
|
||||
setTimeout(() => callRef.current?.focus(), 50);
|
||||
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
|
||||
|
||||
async function send() {
|
||||
const c = call.trim().toUpperCase();
|
||||
const f = parseFloat(freqKHz);
|
||||
if (!c) { setError('Callsign required'); return; }
|
||||
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSend(c, f, message.trim());
|
||||
setOk(true);
|
||||
// Brief success flash, then close.
|
||||
setTimeout(() => { setOk(false); onClose(); }, 700);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(q: RecentSpotQSO) {
|
||||
setCall(q.callsign.toUpperCase());
|
||||
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
||||
if (q.mode) setMessage(q.mode);
|
||||
setError('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Satellite className="size-4 text-primary" /> Send DX Spot
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col flex-1">
|
||||
<Label className="mb-1">Callsign</Label>
|
||||
<Input
|
||||
ref={callRef}
|
||||
className="font-mono uppercase font-bold"
|
||||
value={call}
|
||||
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="DX call"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1">Frequency (kHz)</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={freqKHz}
|
||||
onChange={(e) => setFreqKHz(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="14205"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">Message</Label>
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="e.g. CW · TNX QSO"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1 block">Latest QSOs</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
|
||||
{recent.map((q, i) => (
|
||||
<button
|
||||
key={`${q.callsign}-${i}`}
|
||||
type="button"
|
||||
onClick={() => pick(q)}
|
||||
className="flex w-full items-center gap-2 px-2 py-1 text-left text-xs hover:bg-accent/40"
|
||||
>
|
||||
<span className="font-mono font-bold w-24 truncate">{q.callsign}</span>
|
||||
<span className="font-mono text-muted-foreground w-20 text-right">{q.freqKHz > 0 ? trimKHz(q.freqKHz) : '—'}</span>
|
||||
<span className="text-muted-foreground">{q.mode || ''}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-rose-600">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
||||
{ok ? 'Spot sent ✓' : targetName ? `→ ${targetName}` : 'Master cluster'}
|
||||
</span>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={send} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
||||
{busy ? 'Sending…' : 'Send spot'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// trimKHz formats a kHz value without a trailing ".0" (14205) but keeps
|
||||
// sub-kHz precision when present (10138.7).
|
||||
function trimKHz(khz: number): string {
|
||||
return String(Math.round(khz * 10) / 10).replace(/\.0$/, '');
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -141,6 +142,7 @@ type SectionId =
|
||||
| 'lists-modes'
|
||||
| 'cluster'
|
||||
| 'backup'
|
||||
| 'database'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'rotator'
|
||||
@@ -171,6 +173,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
||||
{ kind: 'item', label: 'Database location', id: 'database' },
|
||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||
],
|
||||
},
|
||||
@@ -196,6 +199,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
'lists-modes': 'Modes & default RST',
|
||||
cluster: 'DX Cluster',
|
||||
backup: 'Database backup',
|
||||
database: 'Database location',
|
||||
udp: 'UDP integrations',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
@@ -319,7 +323,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
|
||||
const updateActive = (patch: Partial<Profile>) =>
|
||||
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
|
||||
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
|
||||
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [] });
|
||||
// RST report lists edited as free text (one/space-separated values).
|
||||
const [rstText, setRstText] = useState({ phone: '', cw: '', digital: '' });
|
||||
// Custom band drafts (catalog covers ADIF spec but the user may have
|
||||
// exotic or experimental bands not listed).
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
@@ -384,6 +390,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [backupRunning, setBackupRunning] = useState(false);
|
||||
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
||||
const [dbMsg, setDbMsg] = useState('');
|
||||
|
||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
|
||||
@@ -457,6 +466,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
setLists(ls);
|
||||
setRstText({
|
||||
phone: ((ls as any).rst_phone ?? []).join(' '),
|
||||
cw: ((ls as any).rst_cw ?? []).join(' '),
|
||||
digital: ((ls as any).rst_digital ?? []).join(' '),
|
||||
});
|
||||
await reloadProfiles();
|
||||
await reloadClusterServers();
|
||||
setCatCfg(c);
|
||||
@@ -464,6 +478,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setBackupCfg(b as any);
|
||||
setQslDefaults(qd as any);
|
||||
setExtSvc(es as any);
|
||||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||||
try {
|
||||
const locs: any = await ListTQSLStationLocations();
|
||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||
@@ -600,7 +615,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
|
||||
}))
|
||||
.filter((m) => m.name !== '');
|
||||
await SaveListsSettings({ bands, modes } as any);
|
||||
const splitList = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
|
||||
await SaveListsSettings({
|
||||
bands, modes,
|
||||
rst_phone: splitList(rstText.phone),
|
||||
rst_cw: splitList(rstText.cw),
|
||||
rst_digital: splitList(rstText.digital),
|
||||
} as any);
|
||||
|
||||
if (activeProfile) {
|
||||
await SaveProfile({
|
||||
@@ -1233,6 +1254,28 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RST report lists — the dropdown choices in the entry form. */}
|
||||
<div className="mt-6 max-w-4xl">
|
||||
<div className="text-sm font-semibold mb-1">RST report lists</div>
|
||||
<div className="text-[11px] text-muted-foreground mb-2">
|
||||
The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Phone (SSB/AM/FM)</Label>
|
||||
<Textarea rows={8} className="font-mono text-xs" value={rstText.phone} onChange={(e) => setRstText((s) => ({ ...s, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">CW / RTTY / PSK</Label>
|
||||
<Textarea rows={8} className="font-mono text-xs" value={rstText.cw} onChange={(e) => setRstText((s) => ({ ...s, cw: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Digital (FT8/FT4/JT…) — dB</Label>
|
||||
<Textarea rows={8} className="font-mono text-xs" value={rstText.digital} onChange={(e) => setRstText((s) => ({ ...s, digital: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2121,6 +2164,74 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function DatabasePanel() {
|
||||
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
|
||||
async function openExisting() {
|
||||
try {
|
||||
const p = await PickOpenDatabase();
|
||||
if (!p) return;
|
||||
await OpenDatabase(p);
|
||||
await refreshDb();
|
||||
setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function saveCopy() {
|
||||
try {
|
||||
const p = await PickSaveDatabase();
|
||||
if (!p) return;
|
||||
await MoveDatabase(p);
|
||||
await refreshDb();
|
||||
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function resetDefault() {
|
||||
try {
|
||||
await ResetDatabaseToDefault();
|
||||
await refreshDb();
|
||||
setDbMsg('Database reset to the default location. Restart OpsLog to apply.');
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Database location"
|
||||
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
|
||||
/>
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="space-y-1">
|
||||
<Label>Current database</Label>
|
||||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||
{dbSettings.path || '—'}
|
||||
{dbSettings.is_custom
|
||||
? <span className="ml-2 text-[10px] text-emerald-700">(custom location)</span>
|
||||
: <span className="ml-2 text-[10px] text-muted-foreground">(default)</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">Default: <span className="font-mono">{dbSettings.default_path}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database…</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch to it…</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
||||
<strong>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
|
||||
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
|
||||
A database change takes effect on the next launch.
|
||||
</div>
|
||||
|
||||
{dbMsg && (
|
||||
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
||||
<span>{dbMsg}</span>
|
||||
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
station: StationPanel,
|
||||
@@ -2134,6 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
cluster: ClusterPanel,
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
backup: BackupPanel,
|
||||
database: DatabasePanel,
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Input } from './input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Searchable combobox: type to filter, click/Enter to pick. On blur it commits
|
||||
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
||||
// can't hold a typo'd value that isn't in the list.
|
||||
export function Combobox({
|
||||
value, onChange, options, placeholder, className, allowFreeText = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
allowFreeText?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, []);
|
||||
|
||||
const filtered = open
|
||||
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
|
||||
: [];
|
||||
|
||||
function commit(v: string) {
|
||||
onChange(v);
|
||||
setQuery(v);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// Defer so a click on an option registers first.
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
|
||||
if (exact) { onChange(exact); setQuery(exact); }
|
||||
else if (allowFreeText) { onChange(query.trim()); }
|
||||
else { setQuery(value); } // revert typo
|
||||
}, 120);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative', className)}>
|
||||
<Input
|
||||
value={open ? query : value}
|
||||
placeholder={placeholder}
|
||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||
// open the list (so tabbing in doesn't pop the dropdown).
|
||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
|
||||
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
|
||||
// Tab: just let it move on; onBlur commits/closes. Options are
|
||||
// tabIndex=-1 so a single Tab leaves the field.
|
||||
}}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="block w-full text-left px-2 py-1 hover:bg-accent/40"
|
||||
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
|
||||
>(({ className, children, hideClose, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
|
||||
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
{!hideOverlay && <DialogOverlay />}
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Maps ADIF DXCC entity numbers to ISO-3166 alpha-2 codes (flagcdn slugs),
|
||||
// so we can show the contacted entity's flag in the UI.
|
||||
//
|
||||
// Why not emoji flags? Windows does NOT render regional-indicator flag
|
||||
// emoji (it shows the two letters instead), and this is a Windows app — so
|
||||
// we use flagcdn.com PNGs keyed by ISO code instead.
|
||||
//
|
||||
// Numbers are taken verbatim from internal/dxcc/adif_numbers.go so they're
|
||||
// guaranteed correct. Coverage is the commonly-worked entities; an unknown
|
||||
// DXCC number simply yields no flag (graceful — better than a wrong one).
|
||||
//
|
||||
// French overseas territories (Corsica, Guadeloupe, Martinique, Réunion,
|
||||
// New Caledonia, French Polynesia…) are mapped to "fr": they fly the French
|
||||
// tricolore and several of their ISO codes aren't served by flagcdn.
|
||||
const DXCC_ISO: Record<number, string> = {
|
||||
// North America
|
||||
1: 'ca', 291: 'us', 6: 'us', 110: 'us', 50: 'mx',
|
||||
202: 'pr', 285: 'vi', 91: 'vg', 69: 'ky', 82: 'jm', 60: 'bs', 64: 'bm',
|
||||
78: 'ht', 72: 'do', 70: 'cu', 62: 'bb', 90: 'tt', 77: 'gd', 97: 'lc',
|
||||
98: 'vc', 95: 'dm', 96: 'ms', 249: 'kn', 94: 'ag', 89: 'tc',
|
||||
79: 'fr', 84: 'fr', 63: 'fr',
|
||||
// Central America
|
||||
66: 'bz', 80: 'hn', 74: 'sv', 86: 'ni', 308: 'cr', 88: 'pa', 76: 'gt',
|
||||
// South America
|
||||
116: 'co', 120: 'ec', 71: 'ec', 136: 'pe', 104: 'bo', 112: 'cl',
|
||||
100: 'ar', 144: 'uy', 132: 'py', 108: 'br', 148: 've', 129: 'gy',
|
||||
140: 'sr', 216: 'co',
|
||||
// Western Europe
|
||||
227: 'fr', 214: 'fr', 230: 'de', 209: 'be', 263: 'nl', 254: 'lu',
|
||||
287: 'ch', 251: 'li', 206: 'at', 248: 'it', 225: 'it', 281: 'es',
|
||||
272: 'pt', 203: 'ad', 278: 'sm', 295: 'va', 260: 'mc',
|
||||
// British Isles
|
||||
223: 'gb-eng', 279: 'gb-sct', 294: 'gb-wls', 265: 'gb-nir', 245: 'ie',
|
||||
114: 'im', 122: 'je', 106: 'gg',
|
||||
// Central / Eastern Europe
|
||||
269: 'pl', 503: 'cz', 504: 'sk', 239: 'hu', 275: 'ro', 212: 'bg',
|
||||
296: 'rs', 514: 'me', 499: 'si', 497: 'hr', 501: 'ba', 502: 'mk',
|
||||
522: 'xk', 7: 'al',
|
||||
// Greece / Mediterranean
|
||||
236: 'gr', 45: 'gr', 40: 'gr', 180: 'gr', 215: 'cy', 257: 'mt', 390: 'tr',
|
||||
// Nordic / Baltic
|
||||
221: 'dk', 222: 'fo', 237: 'gl', 284: 'se', 266: 'no', 224: 'fi',
|
||||
5: 'ax', 242: 'is', 52: 'ee', 145: 'lv', 146: 'lt',
|
||||
// Eastern Europe / Caucasus / Russia
|
||||
27: 'by', 288: 'ua', 179: 'md', 75: 'ge', 14: 'am', 18: 'az',
|
||||
54: 'ru', 15: 'ru', 126: 'ru',
|
||||
// Middle East
|
||||
336: 'il', 342: 'jo', 354: 'lb', 384: 'sy', 378: 'sa', 391: 'ae',
|
||||
304: 'bh', 348: 'kw', 376: 'qa', 370: 'om', 492: 'ye', 330: 'ir', 333: 'iq',
|
||||
// North Africa
|
||||
478: 'eg', 436: 'ly', 400: 'dz', 446: 'ma', 302: 'eh', 474: 'tn',
|
||||
// Sub-Saharan Africa
|
||||
462: 'za', 464: 'na', 402: 'bw', 452: 'zw', 482: 'zm', 181: 'mz',
|
||||
438: 'mg', 165: 'mu', 453: 'fr', 379: 'sc', 430: 'ke', 470: 'tz',
|
||||
286: 'ug', 53: 'et', 51: 'er', 466: 'sd', 521: 'ss', 450: 'ng',
|
||||
424: 'gh', 406: 'cm', 456: 'sn', 434: 'lr', 458: 'sl', 416: 'bj',
|
||||
483: 'tg', 428: 'ci', 442: 'ml', 187: 'ne', 410: 'td', 32: 'cv',
|
||||
420: 'ga', 444: 'mr', 382: 'dj', 454: 'rw', 107: 'gn', 39: 'km', 169: 'fr',
|
||||
// Asia
|
||||
339: 'jp', 137: 'kr', 344: 'kp', 318: 'cn', 324: 'in', 372: 'pk',
|
||||
315: 'lk', 369: 'np', 305: 'bd', 306: 'bt', 309: 'mm', 299: 'my',
|
||||
46: 'my', 381: 'sg', 327: 'id', 375: 'ph', 345: 'bn', 312: 'kh',
|
||||
143: 'la', 387: 'th', 293: 'vn', 130: 'kz', 292: 'uz', 280: 'tm',
|
||||
262: 'tj', 135: 'kg', 3: 'af', 159: 'mv', 363: 'mn', 321: 'hk',
|
||||
386: 'tw', 511: 'tl',
|
||||
// Oceania
|
||||
150: 'au', 163: 'pg', 185: 'sb', 158: 'vu', 176: 'fj', 190: 'ws',
|
||||
170: 'nz', 162: 'fr', 175: 'fr', 160: 'to', 282: 'tv', 301: 'ki',
|
||||
188: 'nu', 168: 'mh', 189: 'nf', 147: 'au',
|
||||
};
|
||||
|
||||
// flagURL returns a flagcdn PNG URL for the given DXCC entity number, or ''
|
||||
// when we don't have a mapping. Height ~20px by default (retina-friendly).
|
||||
export function flagURL(dxcc?: number): string {
|
||||
if (!dxcc) return '';
|
||||
const iso = DXCC_ISO[dxcc];
|
||||
return iso ? `https://flagcdn.com/h20/${iso}.png` : '';
|
||||
}
|
||||
Vendored
+20
@@ -67,6 +67,8 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||
|
||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
@@ -79,6 +81,8 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
@@ -89,6 +93,8 @@ export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
@@ -103,18 +109,30 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenADIFFile():Promise<string>;
|
||||
|
||||
export function OpenDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
|
||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
|
||||
export function PickOpenDatabase():Promise<string>;
|
||||
|
||||
export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
@@ -153,6 +171,8 @@ export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||
|
||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
@@ -114,6 +114,10 @@ export function GetCtyDatInfo() {
|
||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||
}
|
||||
|
||||
export function GetDatabaseSettings() {
|
||||
return window['go']['main']['App']['GetDatabaseSettings']();
|
||||
}
|
||||
|
||||
export function GetExternalServices() {
|
||||
return window['go']['main']['App']['GetExternalServices']();
|
||||
}
|
||||
@@ -138,6 +142,10 @@ export function GetQSO(arg1) {
|
||||
return window['go']['main']['App']['GetQSO'](arg1);
|
||||
}
|
||||
|
||||
export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
|
||||
export function GetRotatorSettings() {
|
||||
return window['go']['main']['App']['GetRotatorSettings']();
|
||||
}
|
||||
@@ -158,6 +166,10 @@ export function ListClusterServers() {
|
||||
return window['go']['main']['App']['ListClusterServers']();
|
||||
}
|
||||
|
||||
export function ListCountries() {
|
||||
return window['go']['main']['App']['ListCountries']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
@@ -186,10 +198,18 @@ export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function MoveDatabase(arg1) {
|
||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function OpenADIFFile() {
|
||||
return window['go']['main']['App']['OpenADIFFile']();
|
||||
}
|
||||
|
||||
export function OpenDatabase(arg1) {
|
||||
return window['go']['main']['App']['OpenDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function OpenExternalURL(arg1) {
|
||||
return window['go']['main']['App']['OpenExternalURL'](arg1);
|
||||
}
|
||||
@@ -202,6 +222,18 @@ export function PickBackupFolder() {
|
||||
return window['go']['main']['App']['PickBackupFolder']();
|
||||
}
|
||||
|
||||
export function PickOpenDatabase() {
|
||||
return window['go']['main']['App']['PickOpenDatabase']();
|
||||
}
|
||||
|
||||
export function PickSaveDatabase() {
|
||||
return window['go']['main']['App']['PickSaveDatabase']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
|
||||
export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
@@ -210,6 +242,10 @@ export function ReloadUDPIntegrations() {
|
||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function ResetDatabaseToDefault() {
|
||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||
}
|
||||
|
||||
export function RotatorGoTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||
}
|
||||
@@ -286,6 +322,10 @@ export function SendClusterCommand(arg1) {
|
||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||
}
|
||||
|
||||
export function SendClusterSpot(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SetCATFrequency(arg1) {
|
||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||
}
|
||||
|
||||
@@ -413,6 +413,22 @@ export namespace main {
|
||||
this.file_mod_time = source["file_mod_time"];
|
||||
}
|
||||
}
|
||||
export class DatabaseSettings {
|
||||
path: string;
|
||||
default_path: string;
|
||||
is_custom: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DatabaseSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.default_path = source["default_path"];
|
||||
this.is_custom = source["is_custom"];
|
||||
}
|
||||
}
|
||||
export class ModePreset {
|
||||
name: string;
|
||||
default_rst_sent?: string;
|
||||
@@ -432,6 +448,9 @@ export namespace main {
|
||||
export class ListsSettings {
|
||||
bands: string[];
|
||||
modes: ModePreset[];
|
||||
rst_phone: string[];
|
||||
rst_cw: string[];
|
||||
rst_digital: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ListsSettings(source);
|
||||
@@ -441,6 +460,9 @@ export namespace main {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.bands = source["bands"];
|
||||
this.modes = this.convertValues(source["modes"], ModePreset);
|
||||
this.rst_phone = source["rst_phone"];
|
||||
this.rst_cw = source["rst_cw"];
|
||||
this.rst_digital = source["rst_digital"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -516,6 +538,24 @@ export namespace main {
|
||||
this.qrzcom_status = source["qrzcom_status"];
|
||||
}
|
||||
}
|
||||
export class RotatorHeading {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
azimuth: number;
|
||||
raw: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorHeading(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.ok = source["ok"];
|
||||
this.azimuth = source["azimuth"];
|
||||
this.raw = source["raw"];
|
||||
}
|
||||
}
|
||||
export class RotatorSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@@ -955,6 +995,8 @@ export namespace qso {
|
||||
hrdlog_qso_upload_status?: string;
|
||||
qrzcom_qso_upload_date?: string;
|
||||
qrzcom_qso_upload_status?: string;
|
||||
qrzcom_qso_download_date?: string;
|
||||
qrzcom_qso_download_status?: string;
|
||||
contest_id?: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
@@ -1060,6 +1102,8 @@ export namespace qso {
|
||||
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
|
||||
this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"];
|
||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
||||
this.contest_id = source["contest_id"];
|
||||
this.srx = source["srx"];
|
||||
this.stx = source["stx"];
|
||||
|
||||
@@ -169,6 +169,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
||||
|
||||
@@ -184,6 +184,7 @@ var adifPromoted = stringSet(
|
||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
@@ -315,6 +316,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
|
||||
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
|
||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||
|
||||
// Contest
|
||||
q.ContestID = rec["contest_id"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- QRZ.com confirmation (download) tracking. Mirrors the upload columns from
|
||||
-- 0014: QRZCOM_QSO_DOWNLOAD_STATUS = 'Y' when QRZ reports the QSO as
|
||||
-- confirmed (matched by the other op), with the date it was pulled.
|
||||
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_status TEXT;
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -147,6 +149,30 @@ func (m *Manager) Lookup(callsign string) (Match, bool) {
|
||||
return db.Lookup(callsign)
|
||||
}
|
||||
|
||||
// EntityNames returns the sorted, de-duplicated DXCC entity names from the
|
||||
// loaded cty.dat — the canonical list for a "Country" picker. Empty until
|
||||
// cty.dat has loaded.
|
||||
func (m *Manager) EntityNames() []string {
|
||||
m.mu.RLock()
|
||||
db := m.db
|
||||
m.mu.RUnlock()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range db.Entities() {
|
||||
n := strings.TrimSpace(e.Name)
|
||||
if n == "" || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Info returns metadata about the currently-loaded cty.dat (or zero value
|
||||
// if nothing loaded).
|
||||
func (m *Manager) Info() ctySource {
|
||||
|
||||
@@ -3,6 +3,7 @@ package extsvc
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -70,6 +71,80 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
|
||||
return parseQRZResponse(string(body))
|
||||
}
|
||||
|
||||
// QRZFetchResult is the parsed outcome of a QRZ FETCH.
|
||||
type QRZFetchResult struct {
|
||||
ADIF string // raw ADIF document
|
||||
Result string // RESULT field (OK / FAIL / AUTH)
|
||||
Count string // COUNT field reported by QRZ
|
||||
}
|
||||
|
||||
// FetchQRZ pulls logbook records as ADIF via the QRZ FETCH action. option is
|
||||
// the QRZ OPTION string (e.g. "ALL"). The ADIF document is returned in the
|
||||
// response's ADIF field.
|
||||
func FetchQRZ(ctx context.Context, client *http.Client, apiKey, option string) (QRZFetchResult, error) {
|
||||
var out QRZFetchResult
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return out, fmt.Errorf("qrz: api key not set")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("KEY", apiKey)
|
||||
form.Set("ACTION", "FETCH")
|
||||
if option != "" {
|
||||
form.Set("OPTION", option)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("qrz: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("qrz: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("qrz: read response: %w", err)
|
||||
}
|
||||
// The response is "RESULT=OK&COUNT=N&ADIF=<adif>". The ADIF blob can
|
||||
// contain '&' and ';', so we can't url.ParseQuery the whole body (Go
|
||||
// caps the number of params). Split off the ADIF value manually and
|
||||
// only query-parse the small status header.
|
||||
full := string(body)
|
||||
head, adifPart := full, ""
|
||||
if i := strings.Index(full, "ADIF="); i >= 0 {
|
||||
head = full[:i]
|
||||
adifPart = full[i+len("ADIF="):]
|
||||
}
|
||||
vals, _ := url.ParseQuery(strings.TrimRight(head, "&"))
|
||||
out.Result = strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
|
||||
out.Count = strings.TrimSpace(vals.Get("COUNT"))
|
||||
if out.Result == "AUTH" || out.Result == "FAIL" {
|
||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||
if reason == "" {
|
||||
reason = "fetch rejected"
|
||||
}
|
||||
return out, fmt.Errorf("qrz: %s", reason)
|
||||
}
|
||||
// The ADIF value may be url-encoded (%3C) and/or HTML-entity-encoded
|
||||
// (QRZ returns < > &). Decode both so the ADIF parser sees
|
||||
// real '<' / '>' tags.
|
||||
if strings.Contains(adifPart, "%3C") || strings.Contains(adifPart, "%3c") {
|
||||
if dec, derr := url.QueryUnescape(adifPart); derr == nil {
|
||||
adifPart = dec
|
||||
}
|
||||
}
|
||||
if strings.Contains(adifPart, "<") || strings.Contains(adifPart, ">") || strings.Contains(adifPart, "&") {
|
||||
adifPart = html.UnescapeString(adifPart)
|
||||
}
|
||||
out.ADIF = adifPart
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
|
||||
// human-readable summary (callsign + QSO count) for the settings UI. An
|
||||
// invalid key comes back as STATUS=AUTH → returned as an error.
|
||||
|
||||
@@ -50,7 +50,7 @@ type Provider interface {
|
||||
// don't (or when no provider returned anything). Decoupled via interface so
|
||||
// `lookup` doesn't import the dxcc package directly.
|
||||
type DXCCResolver interface {
|
||||
Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool)
|
||||
Resolve(callsign string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool)
|
||||
}
|
||||
|
||||
// Manager composes a cache with one or more providers.
|
||||
@@ -210,7 +210,7 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if dxcc == nil {
|
||||
return false
|
||||
}
|
||||
country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
|
||||
dxccNum, country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
@@ -221,8 +221,15 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if ituz != 0 { r.ITUZ = ituz; filled = true }
|
||||
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
|
||||
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
|
||||
// Slashed call → drop QRZ's DXCC# (it's the home call's).
|
||||
if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
|
||||
// cty.dat is authoritative for the *operating* entity: it strips benign
|
||||
// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
|
||||
// Use its DXCC# when known — this overrides the provider's home-call
|
||||
// value AND fixes portable calls like F4BPO/P (same entity, must keep
|
||||
// France's 227). Only when cty.dat can't map a slashed call do we drop
|
||||
// the provider's number rather than mislabel.
|
||||
if dxccNum != 0 {
|
||||
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true }
|
||||
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
|
||||
r.DXCC = 0
|
||||
filled = true
|
||||
}
|
||||
|
||||
+46
-4
@@ -78,6 +78,8 @@ type QSO struct {
|
||||
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
|
||||
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
|
||||
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
||||
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
|
||||
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
|
||||
|
||||
// --- Contest ---
|
||||
ContestID string `json:"contest_id,omitempty"`
|
||||
@@ -167,6 +169,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
clublog_qso_upload_date, clublog_qso_upload_status,
|
||||
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
|
||||
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
|
||||
qrzcom_qso_download_date, qrzcom_qso_download_status,
|
||||
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
|
||||
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
|
||||
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
|
||||
@@ -219,6 +222,7 @@ func (q *QSO) args() []any {
|
||||
q.ClublogUploadDate, q.ClublogUploadStatus,
|
||||
q.HRDLogUploadDate, q.HRDLogUploadStatus,
|
||||
q.QRZComUploadDate, q.QRZComUploadStatus,
|
||||
q.QRZComDownloadDate, q.QRZComDownloadStatus,
|
||||
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
|
||||
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
|
||||
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
|
||||
@@ -1099,14 +1103,35 @@ func SlotKey(dxcc int, band, mode string) string {
|
||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
||||
}
|
||||
|
||||
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos. A QSO
|
||||
// counts as confirmed when any received flag (LoTW, paper, eQSL) is "Y".
|
||||
func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
|
||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||
// OR together (guards the dynamic SQL).
|
||||
var confirmedCols = map[string]bool{
|
||||
"lotw_rcvd": true,
|
||||
"qsl_rcvd": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
}
|
||||
|
||||
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
|
||||
// only the given received-status columns as "confirmed". This lets the caller
|
||||
// scope award-relevant confirmations per service — e.g. LoTW download uses
|
||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||
// {qrzcom_qso_download_status}.
|
||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
|
||||
var conds []string
|
||||
for _, c := range cols {
|
||||
if confirmedCols[c] {
|
||||
conds = append(conds, c+" = 'Y'")
|
||||
}
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return sets, nil
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
|
||||
FROM qso
|
||||
WHERE lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'`)
|
||||
WHERE `+strings.Join(conds, " OR "))
|
||||
if err != nil {
|
||||
return sets, err
|
||||
}
|
||||
@@ -1127,6 +1152,19 @@ func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
|
||||
return sets, rows.Err()
|
||||
}
|
||||
|
||||
// MarkQRZConfirmed stamps QRZCOM_QSO_DOWNLOAD_STATUS=Y and the date on a QSO
|
||||
// confirmed via a QRZ.com download. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
||||
date, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
|
||||
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
|
||||
@@ -1173,6 +1211,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
clublogDate, clublogStatus sql.NullString
|
||||
hrdlogDate, hrdlogStatus sql.NullString
|
||||
qrzcomDate, qrzcomStatus sql.NullString
|
||||
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
||||
contestID sql.NullString
|
||||
srx, stx sql.NullInt64
|
||||
srxStr, stxStr sql.NullString
|
||||
@@ -1205,6 +1244,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&clublogDate, &clublogStatus,
|
||||
&hrdlogDate, &hrdlogStatus,
|
||||
&qrzcomDate, &qrzcomStatus,
|
||||
&qrzcomDlDate, &qrzcomDlStatus,
|
||||
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
|
||||
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
|
||||
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
||||
@@ -1292,6 +1332,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.HRDLogUploadStatus = hrdlogStatus.String
|
||||
q.QRZComUploadDate = qrzcomDate.String
|
||||
q.QRZComUploadStatus = qrzcomStatus.String
|
||||
q.QRZComDownloadDate = qrzcomDlDate.String
|
||||
q.QRZComDownloadStatus = qrzcomDlStatus.String
|
||||
q.ContestID = contestID.String
|
||||
if srx.Valid {
|
||||
v := int(srx.Int64)
|
||||
|
||||
@@ -10,6 +10,7 @@ package pst
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -53,6 +54,59 @@ func (c *Client) Park() error {
|
||||
return c.send("<PST><PARK>1</PARK></PST>")
|
||||
}
|
||||
|
||||
// Heading queries PstRotator for the current azimuth. PstRotator's protocol:
|
||||
// send "<PST>AZ?</PST>" to the command port, and it reports the azimuth back
|
||||
// on UDP port+1. So we bind a listener on port+1 first, send the query, then
|
||||
// read the reply. Returns the raw reply too, for diagnostics. err is non-nil
|
||||
// on timeout (no reply) or an unparseable response.
|
||||
func (c *Client) Heading() (az int, raw string, err error) {
|
||||
// Listen on port+1 where PstRotator sends its position report.
|
||||
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
|
||||
}
|
||||
defer pc.Close()
|
||||
|
||||
if err := c.send("<PST>AZ?</PST>"); err != nil {
|
||||
return 0, "", fmt.Errorf("query PstRotator: %w", err)
|
||||
}
|
||||
|
||||
_ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
|
||||
buf := make([]byte, 512)
|
||||
n, _, rerr := pc.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr)
|
||||
}
|
||||
raw = string(buf[:n])
|
||||
a, ok := parseAzimuth(raw)
|
||||
if !ok {
|
||||
return 0, raw, fmt.Errorf("no azimuth in reply %q", raw)
|
||||
}
|
||||
return a, raw, nil
|
||||
}
|
||||
|
||||
// parseAzimuth extracts the first integer found in a PstRotator reply
|
||||
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
|
||||
// it to [0,360).
|
||||
func parseAzimuth(s string) (int, bool) {
|
||||
i := 0
|
||||
for i < len(s) && (s[i] < '0' || s[i] > '9') {
|
||||
i++
|
||||
}
|
||||
if i >= len(s) {
|
||||
return 0, false
|
||||
}
|
||||
j := i
|
||||
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
|
||||
j++
|
||||
}
|
||||
n, err := strconv.Atoi(s[i:j])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return ((n % 360) + 360) % 360, true
|
||||
}
|
||||
|
||||
func (c *Client) send(payload string) error {
|
||||
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
|
||||
|
||||
Reference in New Issue
Block a user