Initial codebase: Go + Wails amateur radio logbook

Backend (Go 1.25 / Wails v2):
- QSO storage on SQLite (modernc) with embedded migrations (0001..0005)
- Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC
- Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing)
  and SQLite-backed TTL cache
- DXCC resolver from cty.dat (auto-download, longest-prefix-match)
- Multi-profile operator identities (home/portable/SOTA/contest) — every
  QSO stamps MY_* from the active profile
- CAT control via OmniRig COM on a single OS-locked goroutine, with
  bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap
- Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log

Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style):
- Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End
  UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs
- Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges,
  CAT pill with rig selector and clickable Azimuth pill (rotor TODO)
- Settings tree: Profiles (Log4OM-style manager), Station Information
  (edits the active profile), unified Callsign Lookup with Test buttons,
  Bands/Modes lists, CAT
- Worked-before matrix (band × mode × class) with new-DXCC highlighting
- ADIF import from menu + Maintenance > Refresh cty.dat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:16:45 +02:00
parent 734d296300
commit 7ace2cc602
87 changed files with 15892 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>HamLog</title>
</head>
<body>
<div id="root"></div>
<script src="./src/main.tsx" type="module"></script>
</body>
</html>
+3774
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.9.1",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^6.4.2"
}
}
+1
View File
@@ -0,0 +1 @@
58f02c99f9fceb8f5aeae2c8b90fd325
+1357
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
import { useMemo } from 'react';
import { Star, Radio } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
interface Props {
wb: WorkedBefore | null;
busy: boolean;
currentBand: string;
currentMode: string;
}
// 13-column band layout — no 4m, no SHF (per user preference).
const BANDS: { tag: string; label: string }[] = [
{ tag: '160m', label: '160' },
{ tag: '80m', label: '80' },
{ tag: '60m', label: '60' },
{ tag: '40m', label: '40' },
{ tag: '30m', label: '30' },
{ tag: '20m', label: '20' },
{ tag: '17m', label: '17' },
{ tag: '15m', label: '15' },
{ tag: '12m', label: '12' },
{ tag: '10m', label: '10' },
{ tag: '6m', label: '6' },
{ tag: '2m', label: 'V' },
{ tag: '70cm', label: 'U' },
];
const CLASSES = ['PH', 'CW', 'DIG'] as const;
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
function classMatchesMode(cls: string, mode: string): boolean {
const u = (mode || '').toUpperCase();
if (cls === 'PH') return PHONE_MODES.has(u);
if (cls === 'CW') return u === 'CW';
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
}
const STATUS_CLASSES: Record<string, string> = {
call_c: 'bg-emerald-700 hover:bg-emerald-600',
call_w: 'bg-emerald-300 hover:bg-emerald-200',
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
};
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
const desc =
status === 'call_c' ? 'This callsign confirmed' :
status === 'call_w' ? 'This callsign worked (not confirmed)' :
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
'Never worked';
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0;
const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0;
const statusMap = useMemo(() => {
const m = new Map<string, string>();
for (const s of wb?.band_status ?? []) {
m.set(`${s.band}|${s.class}`, s.status);
}
return m;
}, [wb]);
return (
<section
className={cn(
'flex items-center gap-4 px-3 py-2 bg-card border-b border-border flex-wrap shrink-0',
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 border-amber-300',
)}
>
<div className="flex items-center gap-2 min-w-[220px]">
{newOne ? (
<>
<Badge className="bg-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]">
<Star className="size-3 fill-current" />
NEW ONE
</Badge>
<span className="text-xs text-amber-900">
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity
</span>
</>
) : hasDxcc ? (
<>
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
<span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity
</span>
</>
) : busy ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
<Radio className="size-3.5 animate-pulse" />
looking up
</span>
) : (
<span className="text-xs text-muted-foreground italic">
Type a callsign to see entity stats
</span>
)}
</div>
<table className="border-separate" style={{ borderSpacing: 2 }}>
<thead>
<tr>
<th className="w-[22px]" />
{BANDS.map((b) => (
<th
key={b.tag}
className={cn(
'font-mono text-[10px] font-semibold px-1 text-center',
b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground',
)}
>
{b.label}
</th>
))}
</tr>
</thead>
<tbody>
{CLASSES.map((cls) => {
const classCurrent = classMatchesMode(cls, currentMode);
return (
<tr key={cls}>
<th
className={cn(
'font-mono text-[10px] font-semibold pr-1.5 text-right w-[22px]',
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
)}
>
{cls}
</th>
{BANDS.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = b.tag === currentBand && classCurrent;
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent)}
className={cn(
'w-[22px] h-[18px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
isCurrent && 'ring-2 ring-amber-500 ring-inset',
)}
/>
);
})}
</tr>
);
})}
</tbody>
</table>
</section>
);
}
@@ -0,0 +1,119 @@
import { Star } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
interface Props {
wb: WorkedBefore | null;
busy: boolean;
currentCall: string;
}
function fmtDate(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
function fmtDateTime(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
const hasCall = currentCall.trim() !== '';
const count = wb?.count ?? 0;
const entries = wb?.entries ?? [];
return (
<aside className="flex flex-col bg-card border border-border rounded-lg shadow-sm min-h-0 overflow-hidden">
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
<div className="flex items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Worked before
</span>
{hasCall && (
<span className="font-mono text-sm font-bold text-primary tracking-wider">
{currentCall.toUpperCase()}
</span>
)}
</div>
{hasCall && count > 0 && (
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">
{count}×
</Badge>
)}
</header>
{!hasCall ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
Type a callsign to see prior contacts.
</div>
) : busy && count === 0 ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
checking
</div>
) : count === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<Star className="size-6 text-primary fill-current" />
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
<div>No prior QSO with this callsign.</div>
</div>
) : (
<>
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
</div>
<div className="flex-1 overflow-y-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Date UTC</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Band</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Mode</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="hover:bg-stone-50">
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
</td>
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-emerald-100 text-emerald-700">{e.mode}</span>
</td>
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
{e.lotw_rcvd === 'Y' && (
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title="LoTW rcvd">L</span>
)}
{e.qsl_rcvd === 'Y' && (
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title="Bureau rcvd">B</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{count > entries.length && (
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
+ {count - entries.length} older QSOs
</div>
)}
</div>
</>
)}
</aside>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useState } from 'react';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogFooter,
AlertDialogTitle, AlertDialogDescription,
AlertDialogAction, AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
interface Props {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
/** When set, user must type this exact phrase before the confirm button enables. */
confirmPhrase?: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
title = 'Confirm',
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
danger = false,
confirmPhrase = '',
onConfirm,
onCancel,
}: Props) {
const [typed, setTyped] = useState('');
const enabled = confirmPhrase === '' || typed === confirmPhrase;
return (
<AlertDialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
<AlertDialogContent
onKeyDown={(e) => { if (e.key === 'Enter' && enabled) onConfirm(); }}
>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{message}</AlertDialogDescription>
</AlertDialogHeader>
{confirmPhrase && (
<div className="space-y-2">
<Label htmlFor="cd-input" className="text-xs text-foreground">
Type{' '}
<code className="bg-destructive/10 text-destructive font-mono font-bold px-1.5 py-0.5 rounded">
{confirmPhrase}
</code>{' '}
to confirm:
</Label>
<Input
id="cd-input"
autoFocus
value={typed}
onChange={(e) => setTyped(e.target.value)}
className="font-mono"
/>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
disabled={!enabled}
className={cn(
danger && buttonVariants({ variant: 'destructive' }),
!enabled && 'opacity-50 pointer-events-none',
)}
onClick={() => { if (enabled) onConfirm(); }}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+256
View File
@@ -0,0 +1,256 @@
import { useMemo, useState } from 'react';
import { ChevronUp, Construction } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead';
export interface DetailsState {
state: string;
cnty: string;
address: string;
lat?: number;
lon?: number;
// DXCC entity number + zones (filled from QRZ/HamQTH or cty.dat fallback).
// Editable so the operator can correct an obviously wrong auto-fill.
dxcc?: number;
cqz?: number;
ituz?: number;
cont: string;
qsl_msg: string;
qsl_via: string;
ant_az?: number;
ant_el?: number;
ant_path: string;
prop_mode: string;
my_rig: string;
my_antenna: string;
tx_pwr?: number;
sat_name: string;
sat_mode: string;
contest_id: string;
srx?: number;
stx?: number;
email: string;
}
interface Props {
callsign: string;
prefix: string;
operatorGrid: string; // station.my_grid — origin for bearing/distance
remoteGrid: string; // entry-strip Grid value — destination
details: DetailsState;
onChange: (patch: Partial<DetailsState>) => void;
}
type TabName = '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'];
function numOrUndef(v: string): number | undefined {
if (v === '') return undefined;
const n = parseFloat(v);
return isNaN(n) ? undefined : n;
}
// Compact field helper to keep the JSX dense.
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
return (
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
<Label className="mb-1">{label}</Label>
{children}
</div>
);
}
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) {
const [open, setOpen] = useState<TabName | null>(null);
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(
() => pathBetween(operatorGrid, remoteGrid),
[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)); }
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
function setSatellite(on: boolean) {
if (on) {
if (details.prop_mode !== 'SAT') onChange({ prop_mode: 'SAT' });
} else {
onChange({
sat_name: '', sat_mode: '',
...(details.prop_mode === 'SAT' ? { prop_mode: '' } : {}),
});
}
}
const tabs: { key: TabName; label: string }[] = [
{ key: 'info', label: 'Info (F2)' },
{ key: 'awards', label: 'Awards (F3)' },
{ key: 'my', label: 'My (F4)' },
{ key: 'extended', label: 'Extended (F5)' },
];
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">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => toggle(t.key)}
className={cn(
'px-3 py-1.5 text-xs font-medium border-b-2 border-transparent -mb-px transition-colors',
open === t.key
? 'text-primary border-primary font-semibold'
: 'text-muted-foreground hover:text-foreground',
)}
>
{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>
{open === 'info' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="State / pref">
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
</Field>
<Field label="County">
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
</Field>
<Field label="Prefix">
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
</Field>
{/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the
main entry strip — visible without opening F2. F2 keeps the
less-needed long-path bearing and both distances. */}
<Field label="Azimuth LP">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtDeg(path.bearingLong) : ''}
placeholder="—"
/>
</Field>
<Field label="Distance SP">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceShort) : ''}
placeholder="—"
/>
</Field>
<Field label="Distance LP">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceLong) : ''}
placeholder="—"
/>
</Field>
<Field label="Address" span={3}>
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
<Field label="QSL message" span={3}>
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
</Field>
<Field label="QSL via" span={2}>
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
</Field>
</div>
)}
{open === 'awards' && (
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
</div>
)}
{open === 'my' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="Ant. azimuth (°)">
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
</Field>
<Field label="Ant. elevation (°)">
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
</Field>
<Field label="Ant. path">
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
</Field>
<Field label="Propagation">
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label="TX power (W)">
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
</Field>
<div className="flex items-end pb-1.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
Satellite mode
</label>
</div>
<Field label="Rig" span={3}>
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
</Field>
<Field label="Antenna" span={3}>
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
</Field>
{satelliteMode && (
<>
<Field label="Satellite name" span={3}>
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
</Field>
<Field label="Satellite mode" span={3}>
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
</Field>
</>
)}
</div>
)}
{open === 'extended' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="Contest ID" span={2}>
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field>
<Field label="SRX">
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
</Field>
<Field label="STX">
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
</Field>
<Field label="Contacted email" span={3}>
<Input value={details.email} placeholder="op@example.com" onChange={(e) => onChange({ email: e.target.value })} />
</Field>
</div>
)}
</section>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react';
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
export type MenuItem =
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
| { type: 'separator' };
export interface Menu {
name: string;
label: string;
items: MenuItem[];
}
interface Props {
menus: Menu[];
onAction: (action: string) => void;
}
export function Menubar({ menus, onAction }: Props) {
// Track which menu is open so hover-to-switch works like a desktop menubar.
const [openMenu, setOpenMenu] = useState<string | null>(null);
return (
<nav className="flex items-stretch h-full">
{menus.map((menu) => (
<DropdownMenu
key={menu.name}
open={openMenu === menu.name}
onOpenChange={(o) => setOpenMenu(o ? menu.name : null)}
>
<DropdownMenuTrigger
onMouseEnter={() => {
// Only switch on hover if a menu is already open.
if (openMenu !== null && openMenu !== menu.name) setOpenMenu(menu.name);
}}
className={cn(
'px-3 text-sm rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',
openMenu === menu.name && 'bg-muted text-primary',
)}
>
{menu.label}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4} className="min-w-[240px]">
{menu.items.map((item, i) =>
item.type === 'separator' ? (
<DropdownMenuSeparator key={i} />
) : (
<DropdownMenuItem
key={i}
disabled={item.disabled}
onSelect={() => onAction(item.action)}
>
<span>{item.label}</span>
{item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
))}
</nav>
);
}
+373
View File
@@ -0,0 +1,373 @@
import { useEffect, useMemo, useState } from 'react';
import { Trash2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { QSOForm } from '@/types';
type QSO = QSOForm;
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
const QSL_STATUSES = [
{ value: '_', label: '—' },
{ value: 'Y', label: 'Yes' },
{ value: 'N', label: 'No' },
{ value: 'R', label: 'Requested' },
{ value: 'I', label: 'Ignore' },
];
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
interface Props {
qso: QSO;
onSave: (q: QSO) => void;
onDelete: (id: number) => void;
onClose: () => void;
}
function toLocalISO(d: any): string {
if (!d) return '';
const date = new Date(d);
if (isNaN(date.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
}
function parseLocalISO(s: string): string | null {
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return null;
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
}
function stringifyExtras(e?: Record<string, string>): string {
if (!e) return '';
return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n');
}
function parseExtras(t: string): Record<string, string> | undefined {
const out: Record<string, string> = {};
for (const raw of t.split('\n')) {
const line = raw.trim();
if (!line) continue;
const idx = line.indexOf('=');
if (idx < 0) continue;
const k = line.slice(0, idx).trim().toUpperCase();
const v = line.slice(idx + 1).trim();
if (k && v) out[k] = v;
}
return Object.keys(out).length ? out : undefined;
}
function numOrUndef(v: any): number | undefined {
if (v === '' || v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : parseFloat(String(v));
return isNaN(n) ? undefined : n;
}
function intOrUndef(v: any): number | undefined {
const n = numOrUndef(v);
return n === undefined ? undefined : Math.trunc(n);
}
function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
return (
<div className={cn('flex flex-col gap-1 min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
<Label>{label}</Label>
{children}
</div>
);
}
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
return (
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
);
}
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
const [freqMhz, setFreqMhz] = useState(draft.freq_hz ? String(draft.freq_hz / 1_000_000) : '');
const [freqRxMhz, setFreqRxMhz] = useState(draft.freq_rx_hz ? String(draft.freq_rx_hz / 1_000_000) : '');
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false);
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value }));
}
function save() {
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
setSaving(true);
setLocalErr('');
const out: any = {
...draft,
callsign: draft.callsign.trim().toUpperCase(),
grid: (draft.grid ?? '').trim().toUpperCase(),
gridsquare_ext: (draft.gridsquare_ext ?? '').trim().toUpperCase(),
station_callsign: (draft.station_callsign ?? '').trim().toUpperCase(),
operator: (draft.operator ?? '').trim().toUpperCase(),
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
iota: (draft.iota ?? '').trim().toUpperCase(),
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
qso_date_off: parseLocalISO(dateOff) ?? undefined,
freq_hz: freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined,
freq_rx_hz: freqRxMhz.trim() ? Math.round(parseFloat(freqRxMhz) * 1_000_000) : undefined,
dxcc: intOrUndef(draft.dxcc),
cqz: intOrUndef(draft.cqz),
ituz: intOrUndef(draft.ituz),
age: intOrUndef(draft.age),
srx: intOrUndef(draft.srx),
stx: intOrUndef(draft.stx),
my_dxcc: intOrUndef(draft.my_dxcc),
my_cq_zone: intOrUndef(draft.my_cq_zone),
my_itu_zone: intOrUndef(draft.my_itu_zone),
lat: numOrUndef(draft.lat), lon: numOrUndef(draft.lon),
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
tx_pwr: numOrUndef(draft.tx_pwr),
extras: parseExtras(extrasText),
};
onSave(out);
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
});
const extrasCount = useMemo(
() => (draft.extras ? Object.keys(draft.extras).length : 0),
[draft.extras],
);
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>Edit QSO</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
</DialogHeader>
<Tabs defaultValue="basic" className="flex flex-col overflow-hidden min-h-0">
<TabsList className="px-3 overflow-x-auto">
<TabsTrigger value="basic">Basic</TabsTrigger>
<TabsTrigger value="contacted">Contacted</TabsTrigger>
<TabsTrigger value="qsl">QSL</TabsTrigger>
<TabsTrigger value="contest">Contest</TabsTrigger>
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
<TabsTrigger value="mystation">My station</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="extras">
Extras
{extrasCount > 0 && (
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
)}
</TabsTrigger>
</TabsList>
{localErr && (
<div className="mx-5 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2">
{localErr}
</div>
)}
<div className="overflow-y-auto px-5 py-4 flex-1">
<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)} />
</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>
<F label="Band">
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</F>
<F label="Mode">
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</F>
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F>
<F label="Band RX">
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_"></SelectItem>
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
</SelectContent>
</Select>
</F>
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F>
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F>
<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>
</div>
</TabsContent>
<TabsContent value="contacted" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
<F label="QTH" span={3}><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></F>
<F label="Address" span={6}><Input value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></F>
<F label="Email" span={3}><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></F>
<F label="Web" span={3}><Input value={draft.web ?? ''} onChange={(e) => set('web', e.target.value)} /></F>
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
<F label="DXCC"><Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} /></F>
<F label="Cont"><Input value={draft.cont ?? ''} onChange={(e) => set('cont', e.target.value)} /></F>
<F label="CQ zone"><Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} /></F>
<F label="ITU zone"><Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} /></F>
<F label="State"><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></F>
<F label="County"><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></F>
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
<F label="Grid ext"><Input value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} /></F>
<F label="VUCC grids" span={2}><Input value={draft.vucc_grids ?? ''} onChange={(e) => set('vucc_grids', e.target.value)} /></F>
<F label="IOTA"><Input value={draft.iota ?? ''} onChange={(e) => set('iota', e.target.value)} /></F>
<F label="SOTA ref"><Input value={draft.sota_ref ?? ''} onChange={(e) => set('sota_ref', e.target.value)} /></F>
<F label="POTA ref"><Input value={draft.pota_ref ?? ''} onChange={(e) => set('pota_ref', e.target.value)} /></F>
<F label="Age"><Input type="number" value={draft.age ?? ''} onChange={(e) => set('age', intOrUndef(e.target.value) as any)} /></F>
<F label="Latitude"><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} /></F>
<F label="Longitude"><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} /></F>
<F label="Rig (contacted)" span={3}><Input value={draft.rig ?? ''} onChange={(e) => set('rig', e.target.value)} /></F>
<F label="Antenna (contacted)" span={3}><Input value={draft.ant ?? ''} onChange={(e) => set('ant', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="qsl" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="QSL sent"><QslSelect value={draft.qsl_sent ?? ''} onChange={(v) => set('qsl_sent', v)} /></F>
<F label="QSL rcvd"><QslSelect value={draft.qsl_rcvd ?? ''} onChange={(v) => set('qsl_rcvd', v)} /></F>
<F label="QSL sent date"><Input value={draft.qsl_sent_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_sent_date', e.target.value)} /></F>
<F label="QSL rcvd date"><Input value={draft.qsl_rcvd_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_rcvd_date', e.target.value)} /></F>
<F label="QSL via" span={3}><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></F>
<F label="QSL message" span={3}><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></F>
<F label="QSL message rcvd" span={6}><Input value={draft.qslmsg_rcvd ?? ''} onChange={(e) => set('qslmsg_rcvd', e.target.value)} /></F>
<F label="LoTW sent"><QslSelect value={draft.lotw_sent ?? ''} onChange={(v) => set('lotw_sent', v)} /></F>
<F label="LoTW rcvd"><QslSelect value={draft.lotw_rcvd ?? ''} onChange={(v) => set('lotw_rcvd', v)} /></F>
<F label="LoTW sent date"><Input value={draft.lotw_sent_date ?? ''} onChange={(e) => set('lotw_sent_date', e.target.value)} /></F>
<F label="LoTW rcvd date"><Input value={draft.lotw_rcvd_date ?? ''} onChange={(e) => set('lotw_rcvd_date', e.target.value)} /></F>
<F label="eQSL sent"><QslSelect value={draft.eqsl_sent ?? ''} onChange={(v) => set('eqsl_sent', v)} /></F>
<F label="eQSL rcvd"><QslSelect value={draft.eqsl_rcvd ?? ''} onChange={(v) => set('eqsl_rcvd', v)} /></F>
<F label="eQSL sent date"><Input value={draft.eqsl_sent_date ?? ''} onChange={(e) => set('eqsl_sent_date', e.target.value)} /></F>
<F label="eQSL rcvd date"><Input value={draft.eqsl_rcvd_date ?? ''} onChange={(e) => set('eqsl_rcvd_date', e.target.value)} /></F>
<F label="Clublog status" span={2}><Input value={draft.clublog_qso_upload_status ?? ''} onChange={(e) => set('clublog_qso_upload_status', e.target.value)} /></F>
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="sat" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Propagation mode">
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
</Select>
</F>
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="mystation" className="mt-0 space-y-3">
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
<div className="grid grid-cols-6 gap-3">
<F label="Station callsign" span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
<F label="Country" span={2}><Input value={draft.my_country ?? ''} onChange={(e) => set('my_country', e.target.value)} /></F>
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="notes" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Comment" span={6}><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></F>
<F label="Notes" span={6}><Textarea rows={6} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="extras" className="mt-0 space-y-2">
<p className="text-xs text-muted-foreground">
ADIF fields not promoted to first-class columns. One per line:{' '}
<code className="bg-muted px-1 py-0.5 rounded font-mono">FIELD_NAME = value</code>
</p>
<Textarea rows={14} className="font-mono text-xs" value={extrasText} onChange={(e) => setExtrasText(e.target.value)} />
</TabsContent>
</div>
</Tabs>
<DialogFooter className="!flex-row gap-2">
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
<Trash2 className="size-3.5" /> Delete
</Button>
<div className="flex-1" />
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+922
View File
@@ -0,0 +1,922 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import type { main as mainModels } from '../../wailsjs/go/models';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
type Profile = Omit<profileModels.Profile, 'convertValues'>;
const emptyProfile = (): Profile => ({
id: 0,
name: '',
callsign: '', operator: '',
my_grid: '', my_country: '',
my_state: '', my_cnty: '',
my_street: '', my_city: '', my_postal_code: '',
my_sota_ref: '', my_pota_ref: '',
my_rig: '', my_antenna: '',
tx_pwr: undefined,
is_active: false,
sort_order: 0,
created_at: '' as any,
updated_at: '' as any,
});
interface Props {
initialSection?: string;
onClose: () => void;
onSaved: () => void;
}
/* ====== Tree definition ======
Section IDs are stable strings — adding new ones means adding a panel below.
`disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId =
| 'station'
| 'profiles'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
| 'cluster'
| 'backup'
| 'awards'
| 'cat'
| 'rotator'
| 'antenna'
| 'audio';
type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
const TREE: TreeNode[] = [
{
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
{ kind: 'item', label: 'Station Information', id: 'station' },
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
],
},
{
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster', disabled: true },
{ kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
},
{
kind: 'group', label: 'Hardware Configuration', icon: Server, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator', disabled: true },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
],
},
];
// Map section id → friendly name (used in breadcrumb / placeholders).
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
station: 'Station Information',
profiles: 'Profiles',
lookup: 'Callsign Lookup',
'lists-bands': 'Bands',
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Backup / Export',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
antenna: 'Antenna',
audio: 'Audio devices',
};
// ===== Tree component =====
interface TreeProps {
selected: SectionId;
onSelect: (id: SectionId) => void;
}
function Tree({ selected, onSelect }: TreeProps) {
return (
<nav className="text-sm">
{TREE.map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))}
</nav>
);
}
function TreeNodeView({
node, depth, selected, onSelect,
}: { node: TreeNode; depth: number; selected: SectionId; onSelect: (id: SectionId) => void }) {
if (node.kind === 'item') {
const isActive = selected === node.id;
return (
<button
onClick={() => { if (!node.disabled) onSelect(node.id); }}
disabled={node.disabled}
className={cn(
'w-full text-left px-2 py-1.5 rounded-md text-[12.5px] transition-colors flex items-center',
isActive ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60',
node.disabled && 'opacity-50 cursor-not-allowed italic',
)}
style={{ paddingLeft: 8 + depth * 14 }}
>
<span className="truncate">{node.label}</span>
{node.disabled && (
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
)}
</button>
);
}
// group
const [open, setOpen] = useState(node.defaultOpen ?? false);
const Icon = node.icon;
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left px-2 py-1.5 rounded-md text-[12px] uppercase tracking-wider text-muted-foreground hover:text-foreground flex items-center gap-1.5 font-semibold"
style={{ paddingLeft: 8 + depth * 14 }}
>
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{Icon && <Icon className="size-3.5 opacity-70" />}
<span>{node.label}</span>
</button>
{open && (
<div>
{node.children.map((c, i) => (
<TreeNodeView key={i} node={c} depth={depth + 1} selected={selected} onSelect={onSelect} />
))}
</div>
)}
</div>
);
}
// ===== Section content panels =====
function SectionHeader({ title, hint }: { title: string; hint?: string }) {
return (
<header className="mb-4">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
</header>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
return (
<div className="flex flex-col items-center justify-center text-center text-muted-foreground gap-2 py-12">
<IconCmp className="size-10 opacity-40" />
<div className="text-base font-semibold text-foreground/70">{label}</div>
<div className="text-sm">Module coming soon.</div>
</div>
);
}
export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const [lookup, setLookup] = useState<LookupSettings>({
qrz_user: '', qrz_password: '',
hamqth_user: '', hamqth_password: '',
primary: '', failsafe: '',
cache_ttl_days: 30,
});
// Per-provider Test state — keeps the success/error feedback adjacent
// to the button. Cleared on the next test run for that provider.
type TestResult = { ok: boolean; msg: string };
const [lookupTest, setLookupTest] = useState<Record<string, TestResult | undefined>>({});
const [lookupTesting, setLookupTesting] = useState<Record<string, boolean>>({});
// The Station Information panel now edits the full active profile
// (not a flat 6-field StationSettings). Profile selection happens in
// the Profiles panel; any edit here saves back to whichever profile
// is currently active.
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 [bandsText, setBandsText] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [profiles, setProfiles] = useState<Profile[]>([]);
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
// the panel as a plain function, not as a JSX element, so any useState
// inside the panel function would violate the Rules of Hooks.
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
async function reloadProfiles() {
try {
const list = await ListProfiles();
setProfiles(list);
// Refresh the active-profile editor in case activation changed.
const ap = await GetActiveProfile();
setActiveProfile(ap as Profile);
} catch (e: any) {
setErr(String(e?.message ?? e));
}
}
// Keep the ProfilesPanel selector in sync with the loaded list. If the
// currently-selected profile is gone (post-delete) or none is selected
// yet, default to the active one.
useEffect(() => {
if (!profiles.length) return;
const stillThere = profiles.some((p) => (p.id as number) === profileSelectedId);
if (!stillThere) {
const next = profiles.find((p) => p.is_active) ?? profiles[0];
setProfileSelectedId(next.id as number);
setProfileNameDraft(next.name);
}
}, [profiles, profileSelectedId]);
useEffect(() => {
(async () => {
try {
const [l, ls, c, ap] = await Promise.all([
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
]);
setLookup(l);
setActiveProfile(ap as Profile);
setLists(ls);
await reloadProfiles();
setBandsText((ls.bands ?? []).join('\n'));
setCatCfg(c);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
})();
}, []);
function addMode() {
setLists((l) => ({
...l,
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
}));
}
function removeMode(i: number) {
setLists((l) => {
const next = [...(l.modes ?? [])];
next.splice(i, 1);
return { ...l, modes: next };
});
}
function moveMode(i: number, dir: -1 | 1) {
setLists((l) => {
const next = [...(l.modes ?? [])];
const j = i + dir;
if (j < 0 || j >= next.length) return l;
[next[i], next[j]] = [next[j], next[i]];
return { ...l, modes: next };
});
}
function updateMode(i: number, patch: Partial<ModePreset>) {
setLists((l) => {
const next = [...(l.modes ?? [])];
next[i] = { ...next[i], ...patch } as ModePreset;
return { ...l, modes: next };
});
}
async function save() {
setSaving(true); setErr(''); setMsg('');
try {
// Bands: dedup, lowercase, trim.
const seen = new Set<string>();
const bands: string[] = [];
for (const line of bandsText.split('\n')) {
const b = line.trim().toLowerCase();
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
}
const modes = (lists.modes ?? [])
.map((m) => ({
name: (m.name ?? '').trim().toUpperCase(),
default_rst_sent: (m.default_rst_sent ?? '').trim(),
default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
}))
.filter((m) => m.name !== '');
await SaveListsSettings({ bands, modes } as any);
if (activeProfile) {
await SaveProfile({
...activeProfile,
callsign: (activeProfile.callsign ?? '').trim().toUpperCase(),
operator: (activeProfile.operator ?? '').trim().toUpperCase(),
my_grid: (activeProfile.my_grid ?? '').trim().toUpperCase(),
my_sota_ref: (activeProfile.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (activeProfile.my_pota_ref ?? '').trim().toUpperCase(),
} as any);
}
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
setMsg('Settings saved.');
onSaved();
setTimeout(onClose, 500);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setSaving(false);
}
}
async function clearCache() {
setClearing(true); setErr(''); setMsg('');
try {
await ClearLookupCache();
setMsg('Cache cleared.');
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setClearing(false);
}
}
async function testProvider(provider: 'qrz' | 'hamqth') {
setLookupTesting((s) => ({ ...s, [provider]: true }));
setLookupTest((s) => ({ ...s, [provider]: undefined }));
const user = provider === 'qrz' ? lookup.qrz_user : lookup.hamqth_user;
const pwd = provider === 'qrz' ? lookup.qrz_password : lookup.hamqth_password;
try {
const r = await TestLookupProvider(provider, '', user ?? '', pwd ?? '');
setLookupTest((s) => ({ ...s, [provider]: { ok: true, msg: `OK — ${r.callsign} (${r.name || r.country || 'no name'})` } }));
} catch (e: any) {
setLookupTest((s) => ({ ...s, [provider]: { ok: false, msg: String(e?.message ?? e) } }));
} finally {
setLookupTesting((s) => ({ ...s, [provider]: false }));
}
}
const breadcrumb = useMemo(() => SECTION_LABELS[selected] ?? selected, [selected]);
// === Section content renderers ===
function StationPanel() {
if (!activeProfile) {
return <div className="text-muted-foreground text-sm">Loading profile</div>;
}
const p = activeProfile;
return (
<>
<SectionHeader
title="Station Information"
hint={`Editing the active profile: ${p.name}. Switch profiles in the Profiles section to edit a different one.`}
/>
<div className="grid grid-cols-2 gap-3 max-w-2xl">
<div className="space-y-1">
<Label>Station callsign</Label>
<Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
</div>
<div className="space-y-1">
<Label>Operator</Label>
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
</div>
<div className="space-y-1">
<Label>My grid</Label>
<Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
</div>
<div className="space-y-1">
<Label>My country</Label>
<Input value={p.my_country ?? ''} onChange={(e) => updateActive({ my_country: e.target.value })} placeholder="France" />
</div>
<div className="space-y-1">
<Label>State / pref</Label>
<Input value={p.my_state ?? ''} onChange={(e) => updateActive({ my_state: e.target.value })} />
</div>
<div className="space-y-1">
<Label>County</Label>
<Input value={p.my_cnty ?? ''} onChange={(e) => updateActive({ my_cnty: e.target.value })} />
</div>
<div className="space-y-1 col-span-2">
<Label>Street address</Label>
<Input value={p.my_street ?? ''} onChange={(e) => updateActive({ my_street: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Postal code</Label>
<Input value={p.my_postal_code ?? ''} onChange={(e) => updateActive({ my_postal_code: e.target.value })} />
</div>
<div className="space-y-1">
<Label>City</Label>
<Input value={p.my_city ?? ''} onChange={(e) => updateActive({ my_city: e.target.value })} />
</div>
<div className="space-y-1">
<Label>SOTA ref</Label>
<Input className="font-mono uppercase" value={p.my_sota_ref ?? ''} onChange={(e) => updateActive({ my_sota_ref: e.target.value })} placeholder="F/AB-001" />
</div>
<div className="space-y-1">
<Label>POTA ref</Label>
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
</div>
<div className="space-y-1">
<Label>Rig</Label>
<Input value={p.my_rig ?? ''} onChange={(e) => updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" />
</div>
<div className="space-y-1">
<Label>Antenna</Label>
<Input value={p.my_antenna ?? ''} onChange={(e) => updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" />
</div>
<div className="space-y-1">
<Label>TX power (W)</Label>
<Input
type="number"
value={p.tx_pwr ?? ''}
onChange={(e) => updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })}
placeholder="100"
/>
</div>
</div>
</>
);
}
// Profile actions — kept at the SettingsModal level so the ProfilesPanel
// renderer can stay hooks-free (the PANELS map calls it as a plain
// function, not as a JSX component).
const activeProfileObj = profiles.find((p) => p.is_active) ?? profiles[0];
const currentProfile = profiles.find((p) => (p.id as number) === profileSelectedId);
async function profileActivate() {
if (!currentProfile) return;
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileRemove() {
if (!currentProfile) return;
if (!confirm(`Delete profile "${currentProfile.name}"? All its settings will be lost.`)) return;
try { await DeleteProfile(currentProfile.id as number); await reloadProfiles(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileDuplicate() {
if (!currentProfile) return;
const name = prompt(`Name for the new profile (copy of "${currentProfile.name}"):`, `${currentProfile.name} Copy`);
if (!name?.trim()) return;
try {
const dup = await DuplicateProfile(currentProfile.id as number, name.trim());
await reloadProfiles();
setProfileSelectedId(dup.id as number);
setProfileNameDraft(dup.name);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileCreateBlank() {
const name = prompt('Name for the new profile:', 'New profile');
if (!name?.trim()) return;
try {
const blank = emptyProfile();
blank.name = name.trim();
const saved = await SaveProfile(blank as any);
await reloadProfiles();
setProfileSelectedId(saved.id as number);
setProfileNameDraft(saved.name);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileRenameCurrent() {
if (!currentProfile || profileNameDraft.trim() === currentProfile.name) return;
try {
await SaveProfile({ ...currentProfile, name: profileNameDraft.trim() } as any);
await reloadProfiles();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function ProfilesPanel() {
const current = currentProfile;
const active = activeProfileObj;
return (
<>
<SectionHeader
title="Profiles"
hint="Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile."
/>
<div className="space-y-4">
<div className="grid grid-cols-[140px_1fr] items-center gap-x-3 gap-y-3">
<Label>Configuration ID</Label>
<Select
value={String(profileSelectedId)}
onValueChange={(v) => {
const id = parseInt(v, 10);
setProfileSelectedId(id);
setProfileNameDraft(profiles.find((p) => (p.id as number) === id)?.name ?? '');
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{profiles.map((p) => (
<SelectItem key={p.id as number} value={String(p.id)}>
{p.name}{p.callsign ? `${p.callsign}` : ''}{p.is_active ? ' (active)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Label>Description</Label>
<div className="flex items-center gap-2">
<Input
value={profileNameDraft}
onChange={(e) => setProfileNameDraft(e.target.value)}
onBlur={profileRenameCurrent}
placeholder="Profile name"
disabled={!current}
/>
{current?.is_active && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-emerald-100 text-emerald-800 border border-emerald-300">
ACTIVE
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={profileCreateBlank} title="Create a new empty profile">
<Plus className="size-3.5 mr-1" /> New
</Button>
<Button variant="outline" size="sm" onClick={profileDuplicate} disabled={!current} title="Clone the selected profile (keeps all its fields)">
<Copy className="size-3.5 mr-1" /> Duplicate
</Button>
<Button
variant="outline" size="sm"
onClick={profileActivate}
disabled={!current || current.is_active}
title="Activate the selected profile — new QSOs will use its MY_* fields"
>
<Star className="size-3.5 mr-1" /> Set active
</Button>
<Button
variant="outline" size="sm"
onClick={profileRemove}
disabled={!current || profiles.length <= 1}
className="text-destructive hover:text-destructive ml-auto"
title={profiles.length <= 1 ? 'Cannot delete the last profile' : 'Delete the selected profile'}
>
<Trash2 className="size-3.5 mr-1" /> Delete
</Button>
</div>
{current && !current.is_active && (
<div className="text-xs text-muted-foreground bg-muted/30 border border-border rounded-md p-2.5">
You're viewing <strong>{current.name}</strong>. The active profile is <strong>{active?.name}</strong> — its values are stamped on new QSOs. Click <em>Set active</em> to switch.
</div>
)}
</div>
</>
);
}
function LookupPanel() {
// Per-row provider editor — kept inline because it's only used twice
// and needs closure access to the parent state.
const row = (
key: 'qrz' | 'hamqth', label: string, userField: 'qrz_user' | 'hamqth_user',
pwdField: 'qrz_password' | 'hamqth_password',
) => {
const test = lookupTest[key];
const testing = lookupTesting[key];
const hasCreds = !!(lookup[userField] && lookup[pwdField]);
return (
<tr className="border-t border-border align-middle">
<td className="px-3 py-2 font-semibold whitespace-nowrap">{label}</td>
<td className="px-2 py-2 text-center">
<input
type="radio"
name="lookup-primary"
checked={lookup.primary === key}
onChange={() => setLookup((s) => ({ ...s, primary: key, failsafe: s.failsafe === key ? '' : s.failsafe }))}
disabled={!hasCreds}
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="radio"
name="lookup-failsafe"
checked={lookup.failsafe === key}
onChange={() => setLookup((s) => ({ ...s, failsafe: key, primary: s.primary === key ? '' : s.primary }))}
disabled={!hasCreds || lookup.primary === key}
/>
</td>
<td className="px-2 py-2">
<Input
className="h-8"
value={lookup[userField] ?? ''}
onChange={(e) => setLookup((s) => ({ ...s, [userField]: e.target.value }))}
placeholder="User"
autoComplete="off"
/>
</td>
<td className="px-2 py-2">
<Input
className="h-8"
type="password"
value={lookup[pwdField] ?? ''}
onChange={(e) => setLookup((s) => ({ ...s, [pwdField]: e.target.value }))}
placeholder="Password"
autoComplete="off"
/>
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Button
variant="outline" size="sm"
onClick={() => testProvider(key)}
disabled={!hasCreds || testing}
title="Run a sample lookup against the active profile's callsign to verify credentials"
>
{testing ? 'Testing…' : 'Test'}
</Button>
</td>
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-emerald-700' : 'text-destructive')}>
{test?.msg}
</td>
</tr>
);
};
return (
<>
<SectionHeader
title="Callsign Lookup"
hint="Pick a Primary provider and an optional Failsafe (queried only when Primary returns no data). Click Test to verify credentials without saving."
/>
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-3 py-2">Provider</th>
<th className="px-2 py-2 w-20">Primary</th>
<th className="px-2 py-2 w-20">Failsafe</th>
<th className="text-left px-2 py-2">User</th>
<th className="text-left px-2 py-2">Password</th>
<th className="px-2 py-2 w-20"></th>
<th className="text-left px-2 py-2">Result</th>
</tr>
</thead>
<tbody>
{row('qrz', 'QRZ.com', 'qrz_user', 'qrz_password')}
{row('hamqth', 'HamQTH', 'hamqth_user', 'hamqth_password')}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground mt-2">
Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.
</p>
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Cache</h3>
<p className="text-xs text-muted-foreground mb-3">
Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.
</p>
<div className="flex gap-3 items-end">
<div className="space-y-1 w-40">
<Label>TTL (days)</Label>
<Input
type="number" min={1} max={3650}
value={lookup.cache_ttl_days}
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
/>
</div>
<Button variant="outline" onClick={clearCache} disabled={clearing}>
{clearing ? 'Clearing' : 'Clear cache now'}
</Button>
</div>
</div>
</>
);
}
function BandsPanel() {
return (
<>
<SectionHeader title="Bands" hint="One ADIF band per line (e.g. 20m, 2m, 70cm). Order = display order in the entry form and the band-slot grid." />
<Textarea
className="font-mono min-h-[260px] max-w-md"
value={bandsText}
onChange={(e) => setBandsText(e.target.value)}
/>
</>
);
}
function ModesPanel() {
return (
<>
<SectionHeader
title="Modes & default RST"
hint="When you pick a mode in the entry form, RST sent/rcvd auto-fill with these defaults (unless you've typed something)."
/>
<div className="rounded-md border border-border overflow-hidden max-w-2xl">
<div className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-2 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
<span className="w-12">Order</span>
<span>Mode (ADIF)</span>
<span>RST sent</span>
<span>RST rcvd</span>
<span className="w-8"></span>
</div>
<div className="divide-y divide-border">
{(lists.modes ?? []).map((m, i) => (
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-1.5 items-center">
<div className="flex gap-0.5 w-12">
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
<ArrowUp className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === (lists.modes?.length ?? 0) - 1}>
<ArrowDown className="size-3" />
</Button>
</div>
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
</div>
<Button variant="outline" size="sm" onClick={addMode} className="mt-3">
<Plus className="size-3.5" /> Add mode
</Button>
</>
);
}
function CATPanel() {
return (
<>
<SectionHeader
title="CAT interface (OmniRig)"
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — HamLog just talks to it."
/>
<div className="space-y-4 max-w-lg">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
Enable CAT
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Backend</Label>
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>OmniRig rig slot</Label>
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">Rig 1</SelectItem>
<SelectItem value="2">Rig 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Poll interval (ms)</Label>
<Input
type="number" min={50} max={2000} step={50}
value={catCfg.poll_ms}
onChange={(e) => setCatCfg((s) => ({ ...s, poll_ms: parseInt(e.target.value) || 250 }))}
/>
</div>
<div className="space-y-1">
<Label>CAT delay (ms)</Label>
<Input
type="number" min={0} max={500} step={10}
value={catCfg.delay_ms}
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
/>
</div>
<div className="space-y-1 col-span-2">
<Label>Default digital mode (when rig reports DIG)</Label>
<Select
value={catCfg.digital_default || 'FT8'}
onValueChange={(v) => setCatCfg((s) => ({ ...s, digital_default: v }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{['FT8','FT4','RTTY','PSK31','MFSK','JS8','JT65','JT9','OLIVIA','DIGITALVOICE','DATA'].map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
HamLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
{' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu).
OmniRig only reports generic "DIG" for digital modes <strong>Default digital mode</strong>
{' '}is the specific mode HamLog will surface (and log).
</p>
</div>
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel,
profiles: ProfilesPanel,
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: () => <ComingSoon id="cluster" icon={Wifi} />,
backup: () => <ComingSoon id="backup" icon={Database} />,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: () => <ComingSoon id="rotator" icon={Compass} />,
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
audio: () => <ComingSoon id="audio" icon={Server} />,
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[960px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader>
<DialogTitle>Preferences</DialogTitle>
<DialogDescription className="sr-only">Configure HamLog modules station, lookup, hardware</DialogDescription>
</DialogHeader>
{loading ? (
<div className="p-6 text-muted-foreground">Loading</div>
) : (
<div className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden">
{/* Left sidebar tree */}
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
<Tree selected={selected} onSelect={setSelected} />
</aside>
{/* Right content pane */}
<div className="overflow-y-auto p-6">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{breadcrumb}
</div>
{PANELS[selected]?.()}
{err && (
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
{err}
</div>
)}
{msg && (
<div className="mt-6 text-xs rounded-md px-3 py-2 max-w-2xl text-[color:var(--color-ok)] bg-[color:var(--color-ok)]/10 border border-[color:var(--color-ok)]/30">
{msg}
</div>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,97 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-2 text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn('text-base font-semibold', className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel ref={ref} className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)} {...props} />
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+30
View File
@@ -0,0 +1,30 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
accent: 'border-transparent bg-accent text-accent-foreground',
success: 'border-transparent bg-[color:var(--color-ok)]/15 text-[color:var(--color-ok)]',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
xs: 'h-7 rounded-md px-2.5 text-xs',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-input shadow ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+89
View File
@@ -0,0 +1,89 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
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) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-0 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className,
)}
{...props}
>
{children}
{!hideClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-1 px-5 py-4 border-b border-border', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-5 py-3 border-t border-border bg-muted/30 rounded-b-lg', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-base font-semibold leading-none tracking-tight', className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,163 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center justify-between gap-3 rounded-sm px-2.5 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', inset && 'pl-8', className)} {...props} />
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn('ml-auto text-xs tracking-widest opacity-60 font-mono', className)} {...props} />
);
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-xs font-medium text-muted-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,15 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
// Minimal scroll-area wrapper — Radix has a fancier version, but native
// overflow with our styled scrollbar is good enough for the logbook table.
export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('relative overflow-auto', className)} {...props}>
{children}
</div>
),
);
ScrollArea.displayName = 'ScrollArea';
+124
View File
@@ -0,0 +1,124 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton ref={ref} className={cn('flex cursor-default items-center justify-center py-1', className)} {...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton ref={ref} className={cn('flex cursor-default items-center justify-center py-1', className)} {...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-[20rem] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport className={cn('p-1', position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]')}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-xs font-semibold', className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-7 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn('inline-flex h-9 items-center justify-start gap-1 border-b border-border w-full px-2', className)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap px-3 py-1.5 text-xs font-medium text-muted-foreground border-b-2 border-transparent ring-offset-background transition-all hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-primary data-[state=active]:text-primary data-[state=active]:font-semibold -mb-px',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-stone-900 px-2.5 py-1.5 text-xs text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+92
View File
@@ -0,0 +1,92 @@
// Maidenhead grid locator ⇄ lat/lon, plus great-circle distance + bearing.
//
// Used to drive the "AZ SP/LP / Dist SP/LP" readouts in the entry form so
// the operator knows where to point an antenna without having to fire up an
// external tool.
const EARTH_KM = 6371.0088;
const EARTH_CIRCUMFERENCE_KM = 2 * Math.PI * EARTH_KM; // ≈ 40 030
// gridToLatLon parses a Maidenhead locator (4, 6, or 8 chars) and returns
// the center of the indicated square. Returns null on bad input.
export function gridToLatLon(grid: string): { lat: number; lon: number } | null {
if (!grid) return null;
const g = grid.trim().toUpperCase();
if (g.length < 4 || g.length % 2 !== 0) return null;
const A = 'A'.charCodeAt(0);
const Z = 'Z'.charCodeAt(0);
const isLetter = (c: number) => c >= A && c <= Z;
const isDigit = (c: string) => c >= '0' && c <= '9';
if (!isLetter(g.charCodeAt(0)) || !isLetter(g.charCodeAt(1))) return null;
if (!isDigit(g[2]) || !isDigit(g[3])) return null;
let lon = (g.charCodeAt(0) - A) * 20 - 180;
let lat = (g.charCodeAt(1) - A) * 10 - 90;
lon += parseInt(g[2], 10) * 2;
lat += parseInt(g[3], 10);
if (g.length >= 6) {
if (!isLetter(g.charCodeAt(4)) || !isLetter(g.charCodeAt(5))) return null;
lon += (g.charCodeAt(4) - A) * (2 / 24);
lat += (g.charCodeAt(5) - A) * (1 / 24);
// center of the sub-square
lon += 1 / 24;
lat += 0.5 / 24;
} else {
// center of the 2°×1° square
lon += 1;
lat += 0.5;
}
if (g.length >= 8) {
if (!isDigit(g[6]) || !isDigit(g[7])) return null;
// Extended grid (rare) — refine; using simple 10x subdivision.
lon += parseInt(g[6], 10) * (2 / 24 / 10) - 1 / 24;
lat += parseInt(g[7], 10) * (1 / 24 / 10) - 0.5 / 24;
}
return { lat, lon };
}
// PathInfo describes both short and long great-circle path between two
// points. Bearing in degrees from true north (0360). Distance in km.
export interface PathInfo {
bearingShort: number;
bearingLong: number;
distanceShort: number;
distanceLong: number;
}
// pathBetween computes great-circle bearing+distance between two
// Maidenhead grids. Returns null if either is unparseable.
export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
const a = gridToLatLon(fromGrid);
const b = gridToLatLon(toGrid);
if (!a || !b) return null;
const φ1 = toRad(a.lat);
const φ2 = toRad(b.lat);
const Δλ = toRad(b.lon - a.lon);
// Spherical law of cosines is simpler than haversine and accurate enough
// for ham bearings (>1 km errors don't matter at the antenna-rotor level).
let cos = Math.sin(φ1) * Math.sin(φ2) + Math.cos(φ1) * Math.cos(φ2) * Math.cos(Δλ);
cos = Math.max(-1, Math.min(1, cos));
const distShort = EARTH_KM * Math.acos(cos);
// Forward azimuth.
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
let bearing = toDeg(Math.atan2(y, x));
bearing = (bearing + 360) % 360;
return {
bearingShort: bearing,
bearingLong: (bearing + 180) % 360,
distanceShort: distShort,
distanceLong: EARTH_CIRCUMFERENCE_KM - distShort,
};
}
function toRad(d: number): number { return (d * Math.PI) / 180; }
function toDeg(r: number): number { return (r * 180) / Math.PI; }
+11
View File
@@ -0,0 +1,11 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Combine class names: clsx for conditional values + tailwind-merge to
* resolve Tailwind conflicts (e.g. `px-2 px-4` → `px-4`). This is the
* `cn()` helper shadcn/ui uses everywhere.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import {createRoot} from 'react-dom/client'
import './style.css'
import App from './App'
const container = document.getElementById('root')
const root = createRoot(container!)
root.render(
<React.StrictMode>
<App/>
</React.StrictMode>
)
+72
View File
@@ -0,0 +1,72 @@
/* External @imports must come first per CSS spec. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
/* ===== Cream & warm-orange palette =====
Background : warm cream (subtle beige, less cold than stone)
Accent : burnt orange (signal-lamp vibe, classic radio amateur)
Status : muted emerald / amber / rose
The BandSlotGrid cells keep their emerald+indigo independently — that
colour code conveys QSO status semantics, not app branding.
*/
@theme {
--color-background: #faf6ed; /* warm cream */
--color-foreground: #1c1917; /* stone-900 */
--color-card: #ffffff;
--color-card-foreground: #1c1917;
--color-popover: #ffffff;
--color-popover-foreground: #1c1917;
--color-primary: #c2410c; /* orange-700 — burnt orange */
--color-primary-foreground: #fff7ed;
--color-secondary: #f5efe0; /* warmer secondary surface */
--color-secondary-foreground: #1c1917;
--color-muted: #f5efe0;
--color-muted-foreground: #57534e; /* stone-600 */
--color-accent: #ffedd5; /* orange-100 — light cream-orange */
--color-accent-foreground: #9a3412; /* orange-800 */
--color-destructive: #b91c1c; /* red-700, classic brick */
--color-destructive-foreground: #ffffff;
--color-border: #e7dfd0; /* warm beige border */
--color-input: #e7dfd0;
--color-ring: #ea580c; /* orange-600 — focus ring */
--color-ok: #15803d; /* emerald-700 — slightly deeper */
--color-warn: #b45309; /* amber-700 */
--radius: 0.5rem;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Mono', Consolas, 'Courier New', monospace;
}
@layer base {
* { @apply border-border; }
html, body, #root { height: 100%; }
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
}
/* Warm scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: #d6cbb1;
border-radius: 5px;
border: 2px solid var(--color-background);
}
::-webkit-scrollbar-thumb:hover { background: #b3a47d; }
+19
View File
@@ -0,0 +1,19 @@
// Form-friendly views of the Wails-generated model classes.
//
// Wails generates each Go struct as a TS *class* with a hidden `convertValues`
// method used by the runtime for nested struct hydration. That method is
// irrelevant to our UI, but its presence means strict TS rejects any plain
// object literal we try to spread/assign back into the state setter.
//
// We strip it here so React component state stays usable with normal
// `{...prev, key: value}` patterns. Wails class instances remain assignable
// to these views since they have every required property.
import type { main, qso } from '../wailsjs/go/models';
export type LookupSettingsForm = Omit<main.LookupSettings, 'convertValues'>;
export type StationSettingsForm = Omit<main.StationSettings, 'convertValues'>;
export type ListsSettingsForm = Omit<main.ListsSettings, 'convertValues'>;
export type ModePresetForm = Omit<main.ModePreset, 'convertValues'>;
export type QSOForm = Omit<qso.QSO, 'convertValues'>;
export type WorkedBeforeView = Omit<qso.WorkedBefore, 'convertValues'>;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});
+78
View File
@@ -0,0 +1,78 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {qso} from '../models';
import {profile} from '../models';
import {main} from '../models';
import {cat} from '../models';
import {adif} from '../models';
import {lookup} from '../models';
export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function ClearLookupCache():Promise<void>;
export function CountQSO():Promise<number>;
export function DeleteAllQSO():Promise<number>;
export function DeleteProfile(arg1:number):Promise<void>;
export function DeleteQSO(arg1:number):Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetQSO(arg1:number):Promise<qso.QSO>;
export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationSettings():Promise<main.StationSettings>;
export function ImportADIF(arg1:string):Promise<adif.ImportResult>;
export function ListProfiles():Promise<Array<profile.Profile>>;
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function OpenADIFFile():Promise<string>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetCompactMode(arg1:boolean):Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
+143
View File
@@ -0,0 +1,143 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ActivateProfile(arg1) {
return window['go']['main']['App']['ActivateProfile'](arg1);
}
export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache']();
}
export function CountQSO() {
return window['go']['main']['App']['CountQSO']();
}
export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO']();
}
export function DeleteProfile(arg1) {
return window['go']['main']['App']['DeleteProfile'](arg1);
}
export function DeleteQSO(arg1) {
return window['go']['main']['App']['DeleteQSO'](arg1);
}
export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
}
export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
export function GetCATSettings() {
return window['go']['main']['App']['GetCATSettings']();
}
export function GetCATState() {
return window['go']['main']['App']['GetCATState']();
}
export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo']();
}
export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings']();
}
export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings']();
}
export function GetQSO(arg1) {
return window['go']['main']['App']['GetQSO'](arg1);
}
export function GetStartupStatus() {
return window['go']['main']['App']['GetStartupStatus']();
}
export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings']();
}
export function ImportADIF(arg1) {
return window['go']['main']['App']['ImportADIF'](arg1);
}
export function ListProfiles() {
return window['go']['main']['App']['ListProfiles']();
}
export function ListQSO(arg1) {
return window['go']['main']['App']['ListQSO'](arg1);
}
export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1);
}
export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile']();
}
export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
export function SaveCATSettings(arg1) {
return window['go']['main']['App']['SaveCATSettings'](arg1);
}
export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1);
}
export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1);
}
export function SaveProfile(arg1) {
return window['go']['main']['App']['SaveProfile'](arg1);
}
export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetCompactMode(arg1) {
return window['go']['main']['App']['SetCompactMode'](arg1);
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
export function TestLookupProvider(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
}
export function UpdateQSO(arg1) {
return window['go']['main']['App']['UpdateQSO'](arg1);
}
export function WorkedBefore(arg1, arg2) {
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
}
+776
View File
@@ -0,0 +1,776 @@
export namespace adif {
export class ImportResult {
total: number;
imported: number;
skipped: number;
errors: string[];
static createFrom(source: any = {}) {
return new ImportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"];
this.imported = source["imported"];
this.skipped = source["skipped"];
this.errors = source["errors"];
}
}
}
export namespace cat {
export class RigState {
enabled: boolean;
connected: boolean;
backend?: string;
rig_num?: number;
rig?: string;
freq_hz?: number;
freq_rx_hz?: number;
split?: boolean;
mode?: string;
band?: string;
vfo?: string;
error?: string;
// Go type: time
updated_at?: any;
static createFrom(source: any = {}) {
return new RigState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.connected = source["connected"];
this.backend = source["backend"];
this.rig_num = source["rig_num"];
this.rig = source["rig"];
this.freq_hz = source["freq_hz"];
this.freq_rx_hz = source["freq_rx_hz"];
this.split = source["split"];
this.mode = source["mode"];
this.band = source["band"];
this.vfo = source["vfo"];
this.error = source["error"];
this.updated_at = this.convertValues(source["updated_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace lookup {
export class Result {
callsign: string;
name?: string;
qth?: string;
address?: string;
state?: string;
cnty?: string;
country?: string;
grid?: string;
lat?: number;
lon?: number;
dxcc?: number;
cqz?: number;
ituz?: number;
cont?: string;
email?: string;
qsl_via?: string;
source: string;
// Go type: time
fetched_at: any;
static createFrom(source: any = {}) {
return new Result(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.name = source["name"];
this.qth = source["qth"];
this.address = source["address"];
this.state = source["state"];
this.cnty = source["cnty"];
this.country = source["country"];
this.grid = source["grid"];
this.lat = source["lat"];
this.lon = source["lon"];
this.dxcc = source["dxcc"];
this.cqz = source["cqz"];
this.ituz = source["ituz"];
this.cont = source["cont"];
this.email = source["email"];
this.qsl_via = source["qsl_via"];
this.source = source["source"];
this.fetched_at = this.convertValues(source["fetched_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace main {
export class CATSettings {
enabled: boolean;
backend: string;
omnirig_rig: number;
poll_ms: number;
delay_ms: number;
digital_default: string;
static createFrom(source: any = {}) {
return new CATSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];
}
}
export class CtyDatInfo {
path: string;
entities: number;
loaded_at?: string;
file_mod_time?: string;
static createFrom(source: any = {}) {
return new CtyDatInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.entities = source["entities"];
this.loaded_at = source["loaded_at"];
this.file_mod_time = source["file_mod_time"];
}
}
export class ModePreset {
name: string;
default_rst_sent?: string;
default_rst_rcvd?: string;
static createFrom(source: any = {}) {
return new ModePreset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.default_rst_sent = source["default_rst_sent"];
this.default_rst_rcvd = source["default_rst_rcvd"];
}
}
export class ListsSettings {
bands: string[];
modes: ModePreset[];
static createFrom(source: any = {}) {
return new ListsSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.bands = source["bands"];
this.modes = this.convertValues(source["modes"], ModePreset);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class LookupSettings {
qrz_user: string;
qrz_password: string;
hamqth_user: string;
hamqth_password: string;
primary: string;
failsafe: string;
cache_ttl_days: number;
static createFrom(source: any = {}) {
return new LookupSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.qrz_user = source["qrz_user"];
this.qrz_password = source["qrz_password"];
this.hamqth_user = source["hamqth_user"];
this.hamqth_password = source["hamqth_password"];
this.primary = source["primary"];
this.failsafe = source["failsafe"];
this.cache_ttl_days = source["cache_ttl_days"];
}
}
export class StartupStatus {
ok: boolean;
err: string;
db_path: string;
static createFrom(source: any = {}) {
return new StartupStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ok = source["ok"];
this.err = source["err"];
this.db_path = source["db_path"];
}
}
export class StationSettings {
callsign: string;
operator: string;
my_grid: string;
my_country: string;
my_sota_ref: string;
my_pota_ref: string;
static createFrom(source: any = {}) {
return new StationSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.operator = source["operator"];
this.my_grid = source["my_grid"];
this.my_country = source["my_country"];
this.my_sota_ref = source["my_sota_ref"];
this.my_pota_ref = source["my_pota_ref"];
}
}
}
export namespace profile {
export class Profile {
id: number;
name: string;
callsign: string;
operator: string;
my_grid: string;
my_country: string;
my_state: string;
my_cnty: string;
my_street: string;
my_city: string;
my_postal_code: string;
my_sota_ref: string;
my_pota_ref: string;
my_rig: string;
my_antenna: string;
tx_pwr?: number;
is_active: boolean;
sort_order: number;
// Go type: time
created_at: any;
// Go type: time
updated_at: any;
static createFrom(source: any = {}) {
return new Profile(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.callsign = source["callsign"];
this.operator = source["operator"];
this.my_grid = source["my_grid"];
this.my_country = source["my_country"];
this.my_state = source["my_state"];
this.my_cnty = source["my_cnty"];
this.my_street = source["my_street"];
this.my_city = source["my_city"];
this.my_postal_code = source["my_postal_code"];
this.my_sota_ref = source["my_sota_ref"];
this.my_pota_ref = source["my_pota_ref"];
this.my_rig = source["my_rig"];
this.my_antenna = source["my_antenna"];
this.tx_pwr = source["tx_pwr"];
this.is_active = source["is_active"];
this.sort_order = source["sort_order"];
this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace qso {
export class BandMode {
band: string;
mode: string;
static createFrom(source: any = {}) {
return new BandMode(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.band = source["band"];
this.mode = source["mode"];
}
}
export class BandStatus {
band: string;
class: string;
status: string;
static createFrom(source: any = {}) {
return new BandStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.band = source["band"];
this.class = source["class"];
this.status = source["status"];
}
}
export class ListFilter {
callsign?: string;
band?: string;
mode?: string;
station_callsign?: string;
limit?: number;
offset?: number;
static createFrom(source: any = {}) {
return new ListFilter(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.band = source["band"];
this.mode = source["mode"];
this.station_callsign = source["station_callsign"];
this.limit = source["limit"];
this.offset = source["offset"];
}
}
export class QSO {
id: number;
callsign: string;
// Go type: time
qso_date: any;
// Go type: time
qso_date_off?: any;
band: string;
band_rx?: string;
mode: string;
submode?: string;
freq_hz?: number;
freq_rx_hz?: number;
rst_sent?: string;
rst_rcvd?: string;
name?: string;
qth?: string;
address?: string;
email?: string;
web?: string;
grid?: string;
gridsquare_ext?: string;
vucc_grids?: string;
country?: string;
state?: string;
cnty?: string;
dxcc?: number;
cont?: string;
cqz?: number;
ituz?: number;
iota?: string;
sota_ref?: string;
pota_ref?: string;
age?: number;
lat?: number;
lon?: number;
rig?: string;
ant?: string;
qsl_sent?: string;
qsl_rcvd?: string;
qsl_sent_date?: string;
qsl_rcvd_date?: string;
qsl_via?: string;
qsl_msg?: string;
qslmsg_rcvd?: string;
lotw_sent?: string;
lotw_rcvd?: string;
lotw_sent_date?: string;
lotw_rcvd_date?: string;
eqsl_sent?: string;
eqsl_rcvd?: string;
eqsl_sent_date?: string;
eqsl_rcvd_date?: string;
clublog_qso_upload_date?: string;
clublog_qso_upload_status?: string;
hrdlog_qso_upload_date?: string;
hrdlog_qso_upload_status?: string;
contest_id?: string;
srx?: number;
stx?: number;
srx_string?: string;
stx_string?: string;
check?: string;
precedence?: string;
arrl_sect?: string;
prop_mode?: string;
sat_name?: string;
sat_mode?: string;
ant_az?: number;
ant_el?: number;
ant_path?: string;
station_callsign?: string;
operator?: string;
my_grid?: string;
my_gridsquare_ext?: string;
my_country?: string;
my_state?: string;
my_cnty?: string;
my_iota?: string;
my_sota_ref?: string;
my_pota_ref?: string;
my_dxcc?: number;
my_cq_zone?: number;
my_itu_zone?: number;
my_lat?: number;
my_lon?: number;
my_street?: string;
my_city?: string;
my_postal_code?: string;
my_rig?: string;
my_antenna?: string;
tx_pwr?: number;
comment?: string;
notes?: string;
extras?: Record<string, string>;
// Go type: time
created_at: any;
// Go type: time
updated_at: any;
static createFrom(source: any = {}) {
return new QSO(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.callsign = source["callsign"];
this.qso_date = this.convertValues(source["qso_date"], null);
this.qso_date_off = this.convertValues(source["qso_date_off"], null);
this.band = source["band"];
this.band_rx = source["band_rx"];
this.mode = source["mode"];
this.submode = source["submode"];
this.freq_hz = source["freq_hz"];
this.freq_rx_hz = source["freq_rx_hz"];
this.rst_sent = source["rst_sent"];
this.rst_rcvd = source["rst_rcvd"];
this.name = source["name"];
this.qth = source["qth"];
this.address = source["address"];
this.email = source["email"];
this.web = source["web"];
this.grid = source["grid"];
this.gridsquare_ext = source["gridsquare_ext"];
this.vucc_grids = source["vucc_grids"];
this.country = source["country"];
this.state = source["state"];
this.cnty = source["cnty"];
this.dxcc = source["dxcc"];
this.cont = source["cont"];
this.cqz = source["cqz"];
this.ituz = source["ituz"];
this.iota = source["iota"];
this.sota_ref = source["sota_ref"];
this.pota_ref = source["pota_ref"];
this.age = source["age"];
this.lat = source["lat"];
this.lon = source["lon"];
this.rig = source["rig"];
this.ant = source["ant"];
this.qsl_sent = source["qsl_sent"];
this.qsl_rcvd = source["qsl_rcvd"];
this.qsl_sent_date = source["qsl_sent_date"];
this.qsl_rcvd_date = source["qsl_rcvd_date"];
this.qsl_via = source["qsl_via"];
this.qsl_msg = source["qsl_msg"];
this.qslmsg_rcvd = source["qslmsg_rcvd"];
this.lotw_sent = source["lotw_sent"];
this.lotw_rcvd = source["lotw_rcvd"];
this.lotw_sent_date = source["lotw_sent_date"];
this.lotw_rcvd_date = source["lotw_rcvd_date"];
this.eqsl_sent = source["eqsl_sent"];
this.eqsl_rcvd = source["eqsl_rcvd"];
this.eqsl_sent_date = source["eqsl_sent_date"];
this.eqsl_rcvd_date = source["eqsl_rcvd_date"];
this.clublog_qso_upload_date = source["clublog_qso_upload_date"];
this.clublog_qso_upload_status = source["clublog_qso_upload_status"];
this.hrdlog_qso_upload_date = source["hrdlog_qso_upload_date"];
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
this.contest_id = source["contest_id"];
this.srx = source["srx"];
this.stx = source["stx"];
this.srx_string = source["srx_string"];
this.stx_string = source["stx_string"];
this.check = source["check"];
this.precedence = source["precedence"];
this.arrl_sect = source["arrl_sect"];
this.prop_mode = source["prop_mode"];
this.sat_name = source["sat_name"];
this.sat_mode = source["sat_mode"];
this.ant_az = source["ant_az"];
this.ant_el = source["ant_el"];
this.ant_path = source["ant_path"];
this.station_callsign = source["station_callsign"];
this.operator = source["operator"];
this.my_grid = source["my_grid"];
this.my_gridsquare_ext = source["my_gridsquare_ext"];
this.my_country = source["my_country"];
this.my_state = source["my_state"];
this.my_cnty = source["my_cnty"];
this.my_iota = source["my_iota"];
this.my_sota_ref = source["my_sota_ref"];
this.my_pota_ref = source["my_pota_ref"];
this.my_dxcc = source["my_dxcc"];
this.my_cq_zone = source["my_cq_zone"];
this.my_itu_zone = source["my_itu_zone"];
this.my_lat = source["my_lat"];
this.my_lon = source["my_lon"];
this.my_street = source["my_street"];
this.my_city = source["my_city"];
this.my_postal_code = source["my_postal_code"];
this.my_rig = source["my_rig"];
this.my_antenna = source["my_antenna"];
this.tx_pwr = source["tx_pwr"];
this.comment = source["comment"];
this.notes = source["notes"];
this.extras = source["extras"];
this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class WorkedEntry {
id: number;
// Go type: time
qso_date: any;
band: string;
mode: string;
rst_sent?: string;
rst_rcvd?: string;
qsl_sent?: string;
qsl_rcvd?: string;
lotw_sent?: string;
lotw_rcvd?: string;
static createFrom(source: any = {}) {
return new WorkedEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.qso_date = this.convertValues(source["qso_date"], null);
this.band = source["band"];
this.mode = source["mode"];
this.rst_sent = source["rst_sent"];
this.rst_rcvd = source["rst_rcvd"];
this.qsl_sent = source["qsl_sent"];
this.qsl_rcvd = source["qsl_rcvd"];
this.lotw_sent = source["lotw_sent"];
this.lotw_rcvd = source["lotw_rcvd"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class WorkedBefore {
callsign: string;
count: number;
// Go type: time
first?: any;
// Go type: time
last?: any;
bands: string[];
modes: string[];
band_modes: BandMode[];
entries: WorkedEntry[];
dxcc?: number;
dxcc_name?: string;
dxcc_count: number;
// Go type: time
dxcc_first?: any;
// Go type: time
dxcc_last?: any;
dxcc_bands: string[];
dxcc_modes: string[];
dxcc_band_modes: BandMode[];
band_status: BandStatus[];
static createFrom(source: any = {}) {
return new WorkedBefore(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.count = source["count"];
this.first = this.convertValues(source["first"], null);
this.last = this.convertValues(source["last"], null);
this.bands = source["bands"];
this.modes = source["modes"];
this.band_modes = this.convertValues(source["band_modes"], BandMode);
this.entries = this.convertValues(source["entries"], WorkedEntry);
this.dxcc = source["dxcc"];
this.dxcc_name = source["dxcc_name"];
this.dxcc_count = source["dxcc_count"];
this.dxcc_first = this.convertValues(source["dxcc_first"], null);
this.dxcc_last = this.convertValues(source["dxcc_last"], null);
this.dxcc_bands = source["dxcc_bands"];
this.dxcc_modes = source["dxcc_modes"];
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode);
this.band_status = this.convertValues(source["band_status"], BandStatus);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+249
View File
@@ -0,0 +1,249 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
+242
View File
@@ -0,0 +1,242 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}