chore: release v0.26.4
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase,
|
||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
GetPSUSettings, SavePSUSettings,
|
||||
@@ -1838,6 +1839,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
};
|
||||
// A date for a person: the day, not the timestamp the backend stores.
|
||||
const fmtDay = (v?: string) => (v ? String(v).slice(0, 10) : '—');
|
||||
// Binary units, one decimal, because the point of showing a size here is to
|
||||
// compare two of them: "412.7 MB → 38.4 MB" says what a bare byte count does not.
|
||||
const fmtBytes = (n: number) => {
|
||||
if (!n || n < 0) return '—';
|
||||
const u = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||||
return `${i === 0 ? v : v.toFixed(1)} ${u[i]}`;
|
||||
};
|
||||
const [rdaCount, setRdaCount] = useState(0);
|
||||
const [rdaBusy, setRdaBusy] = useState(false);
|
||||
const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
|
||||
@@ -3591,6 +3602,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
options={wkPorts}
|
||||
allowFreeText
|
||||
commitOnType
|
||||
showToggle
|
||||
placeholder="COM3"
|
||||
className="font-mono flex-1"
|
||||
onChange={(v) => setUltrabeam((s) => ({ ...s, com: v.trim().toUpperCase() }))}
|
||||
@@ -6166,6 +6178,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function DatabasePanel() {
|
||||
// Compacting: which database is running, and what the last one saved.
|
||||
// SQLite frees pages inside the file and never shrinks it, so this is the
|
||||
// only thing that gives the disk space back after a large delete.
|
||||
const [compacting, setCompacting] = useState('');
|
||||
const [compactMsg, setCompactMsg] = useState<Record<string, string>>({});
|
||||
function compact(target: 'settings' | 'logbook') {
|
||||
setCompacting(target);
|
||||
setCompactMsg((m) => ({ ...m, [target]: '' }));
|
||||
CompactDatabase(target)
|
||||
.then((r: any) => {
|
||||
const msg = r?.backend === 'mysql'
|
||||
? t('db.compactMysqlDone')
|
||||
: t('db.compactDone', { before: fmtBytes(r?.before ?? 0), after: fmtBytes(r?.after ?? 0) });
|
||||
setCompactMsg((m) => ({ ...m, [target]: msg }));
|
||||
})
|
||||
.catch((e: any) => setErr(String(e?.message ?? e)))
|
||||
.finally(() => setCompacting(''));
|
||||
}
|
||||
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
|
||||
async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} }
|
||||
// The chosen file is persisted (config.json) the moment it's picked; dbMsg
|
||||
@@ -6285,9 +6315,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||
{/* Pushed right: these two act on the file that is already there,
|
||||
while the four on the left change WHICH file is in use. */}
|
||||
<Button variant="outline" size="sm" className="ml-auto" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||
<Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('settings')} disabled={compacting !== ''}>
|
||||
{compacting === 'settings' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||
</div>
|
||||
{compactMsg.settings && <p className="text-[11px] text-success">{compactMsg.settings}</p>}
|
||||
{/* The DB pointer is only read at startup, so offer the restart inline. */}
|
||||
{dbMsg && (
|
||||
<div className="text-[11px] text-success space-y-1 pt-1">
|
||||
@@ -6350,8 +6384,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
|
||||
<Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('logbook')} disabled={compacting !== ''}>
|
||||
{compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
|
||||
</Button>
|
||||
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
|
||||
</div>
|
||||
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6376,8 +6414,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{t('db.testCreate')}
|
||||
</Button>
|
||||
<Button size="sm" className="h-8" onClick={connectMysql}>{t('db.connectUse')}</Button>
|
||||
{/* Compacting a shared logbook is OPTIMIZE TABLE, and it rebuilds the
|
||||
table with everyone else waiting on it — hence the warning, and
|
||||
hence its place here rather than beside the connection buttons of
|
||||
a file only this operator uses. */}
|
||||
<Button variant="outline" size="sm" className="h-8 ml-auto" title={t('db.compactMysqlWarn')}
|
||||
onClick={() => compact('logbook')} disabled={compacting !== ''}>
|
||||
{compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
||||
</div>
|
||||
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Input } from './input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
// can't hold a typo'd value that isn't in the list.
|
||||
export function Combobox({
|
||||
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||
showToggle = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
@@ -18,9 +20,21 @@ export function Combobox({
|
||||
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
||||
// leaving the field uses the value just typed.
|
||||
commitOnType?: boolean;
|
||||
// Draw a chevron that opens the full list on click.
|
||||
//
|
||||
// Without it this control is indistinguishable from a plain text box: it opens
|
||||
// only on a keystroke or ArrowDown, both of which have to be known about
|
||||
// first. That is fine for a field whose list is a convenience (RST), and wrong
|
||||
// for one whose list is the ANSWER — the COM ports actually present on this
|
||||
// machine, which nobody can be expected to recall.
|
||||
showToggle?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
// Opened by the chevron rather than by typing: the whole list is on offer, not
|
||||
// the part matching what is already in the field — a box holding COM7 would
|
||||
// otherwise "open" onto COM7 alone.
|
||||
const [browse, setBrowse] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,20 +45,22 @@ export function Combobox({
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, []);
|
||||
|
||||
const filtered = open
|
||||
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
|
||||
: [];
|
||||
const filtered = !open ? []
|
||||
: browse ? options.slice(0, 60)
|
||||
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
|
||||
|
||||
function commit(v: string) {
|
||||
onChange(v);
|
||||
setQuery(v);
|
||||
setOpen(false);
|
||||
setBrowse(false);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// Defer so a click on an option registers first.
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
setBrowse(false);
|
||||
const trimmed = query.trim();
|
||||
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
|
||||
// Only fire onChange when the value actually changed — committing an
|
||||
@@ -68,6 +84,7 @@ export function Combobox({
|
||||
const v = e.target.value;
|
||||
setQuery(v);
|
||||
setOpen(true);
|
||||
setBrowse(false); // typing filters again
|
||||
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||
// without leaving the field uses what was just typed). With free text that's
|
||||
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||
@@ -76,14 +93,34 @@ export function Combobox({
|
||||
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
className={showToggle ? 'pr-7' : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); setBrowse(true); }
|
||||
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
|
||||
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
|
||||
// Tab: just let it move on; onBlur commits/closes. Options are
|
||||
// tabIndex=-1 so a single Tab leaves the field.
|
||||
}}
|
||||
/>
|
||||
{showToggle && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Show list"
|
||||
className="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
// mousedown, not click: the input's blur fires first otherwise and
|
||||
// closes the list the same instant this opens it.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
if (open) { setOpen(false); setBrowse(false); return; }
|
||||
setQuery(value);
|
||||
setBrowse(true);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
||||
{filtered.map((o) => (
|
||||
|
||||
Reference in New Issue
Block a user