The watch list was a tab, and an operator working FT8 lives on the decodes one: a station they had asked to be told about turned up on a screen they were not looking at. The same answer is now docked in the widget strip, above the tabs, reduced to what is worth acting on — on the air and still needed, one row per band and mode, with the cluster's own NEW DXCC / NEW BAND / NEW SLOT badge and a click that tunes. Off by default. The "active and needed" answer costs a debounced query per visible slot, so it is written once (lib/watchlistSpots) and the tab uses it too. Auto-call: - It answers a new prefix, county, state, square or park. Those markers are orthogonal to the entity, they ranked as nothing-needed, and the engine sat through a never-worked WPX prefix calling CQ. New rung at the foot of the ladder, gated by the chase switches the badges use — which meant making those switches portable, since the backend cannot read localStorage. - It calls THROUGH a pileup. Giving up the moment the DX answered somebody else is precisely how a queue is not worked; the call and miss counters already bound the effort, and a station in mid-exchange is still never chosen as a new target. The PSK Reporter panel now follows the station auto-call is waiting for: the analysis takes a history query and a period or two to fill, so starting it when the DX comes free is starting it too late. Callbook lookup: a compound callsign with a page of its OWN keeps that page's location. QRZ files HP/WE9G under exactly that form, with the Panama square the station is operating from, and the rule that drops a home address from a portable call was throwing it away. The record's own country tells an operation's page from a home page. Changelog: entries may open with [NEW], drawn as a pill in the What's new dialog — a release is mostly fixes and the two or three genuinely new things should not have to be found by reading all of it.
408 lines
19 KiB
TypeScript
408 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
|
import { GripVertical, Lock } from 'lucide-react';
|
|
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { cn } from '@/lib/utils';
|
|
import type { RowColorSettings } from '@/lib/rowColors';
|
|
import {
|
|
MATRIX_VARS, applyMatrixColors, effectiveMatrixColor, emptyMatrixColors,
|
|
type MatrixColors,
|
|
} from '@/lib/matrixColors';
|
|
|
|
// A fixed palette plus a free picker. Muted values on purpose: they are
|
|
// composited at low opacity over a dark grid, where a saturated colour reads as
|
|
// an error state rather than a status.
|
|
const PALETTE = [
|
|
'#16a34a', '#0ea5e9', '#f59e0b', '#a855f7',
|
|
'#dc2626', '#14b8a6', '#eab308', '#ec4899',
|
|
'#64748b', '#84cc16', '#6366f1', '#f97316',
|
|
];
|
|
|
|
// The rule ids the backend orders; the labels live here so a translation never
|
|
// travels through the settings row.
|
|
const LABELS: Record<string, string> = {
|
|
to_send: 'appr.ruleToSend',
|
|
confirmed: 'appr.ruleConfirmed',
|
|
sent: 'appr.ruleSent',
|
|
worked: 'appr.ruleWorked',
|
|
};
|
|
|
|
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
|
// nothing on ANY channel — so narrowing it would say nothing.
|
|
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const;
|
|
const CHANNEL_LABELS: Record<string, string> = {
|
|
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', hamlog: 'HAMLOG.online',
|
|
};
|
|
|
|
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
|
|
// Stats panel.
|
|
//
|
|
// The pickers are seeded from what the matrix is painting RIGHT NOW (the active
|
|
// theme's ramp, or an existing override), not from a fixed palette: the operator
|
|
// starts from the colours in front of them and moves one, instead of being
|
|
// handed six values that may belong to a theme they stopped using. Every change
|
|
// is applied to the live document at once, so the sample row below is the real
|
|
// thing rather than a mock-up of it.
|
|
function MatrixColorsSection() {
|
|
const { t } = useI18n();
|
|
const [cfg, setCfg] = useState<MatrixColors | null>(null);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
setCfg((await GetMatrixColors()) as any);
|
|
} catch {
|
|
setCfg(emptyMatrixColors());
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const save = (next: MatrixColors) => {
|
|
setCfg(next);
|
|
applyMatrixColors(next); // live, before the round trip — the panel must not lag the choice
|
|
SaveMatrixColors(next as any).catch(() => {});
|
|
};
|
|
|
|
// Turning it ON with nothing stored would change nothing at all and read as a
|
|
// broken switch, so the empty slots are filled from the theme's current ramp:
|
|
// the operator sees six swatches that match the grid and edits from there.
|
|
const enable = (on: boolean) => {
|
|
if (!cfg) return;
|
|
if (!on) {
|
|
save({ ...cfg, enabled: false });
|
|
return;
|
|
}
|
|
const seeded = { ...cfg, enabled: true };
|
|
for (const { key, cssVar } of MATRIX_VARS) {
|
|
if (!String(seeded[key] ?? '').trim()) seeded[key] = effectiveMatrixColor(cssVar);
|
|
}
|
|
save(seeded);
|
|
};
|
|
|
|
// Reset clears the overrides but keeps the section switched on, then re-seeds
|
|
// from the theme — "back to the theme's colours", which is what an operator
|
|
// means by reset here, rather than "switch the whole feature off".
|
|
const reset = () => {
|
|
if (!cfg) return;
|
|
applyMatrixColors({ ...emptyMatrixColors(), enabled: false });
|
|
const seeded = { ...emptyMatrixColors(), enabled: true };
|
|
for (const { key, cssVar } of MATRIX_VARS) seeded[key] = effectiveMatrixColor(cssVar);
|
|
save(seeded);
|
|
};
|
|
|
|
if (!cfg) return null;
|
|
|
|
return (
|
|
<div className="space-y-3 border-t border-border/60 pt-4">
|
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={cfg.enabled} className="mt-0.5" onCheckedChange={(c) => enable(!!c)} />
|
|
<span>
|
|
{t('appr.matrixEnable')}{' '}
|
|
<span className="text-xs text-muted-foreground">{t('appr.matrixHint')}</span>
|
|
</span>
|
|
</label>
|
|
|
|
{cfg.enabled && (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
{MATRIX_VARS.map(({ key, cssVar, label }) => (
|
|
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="color"
|
|
value={String(cfg[key] || '').trim() || effectiveMatrixColor(cssVar)}
|
|
onChange={(e) => save({ ...cfg, [key]: e.target.value })}
|
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer shrink-0"
|
|
/>
|
|
{t(label)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{/* The matrix as it will actually look: same tokens, same shapes. */}
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground w-10 shrink-0">{t('appr.matrixSample')}</span>
|
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-conf" />
|
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-work" />
|
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-conf" />
|
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
|
|
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
|
|
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
|
|
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
|
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-work ring-1 ring-background" />
|
|
</span>
|
|
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
|
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-conf ring-1 ring-background" />
|
|
</span>
|
|
</div>
|
|
|
|
<button type="button" onClick={reset}
|
|
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
|
|
{t('appr.matrixReset')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AppearancePanel() {
|
|
const { t } = useI18n();
|
|
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try { setCfg((await GetRowColors()) as any); } catch { /* defaults on the backend */ }
|
|
})();
|
|
}, []);
|
|
|
|
const save = (next: RowColorSettings) => {
|
|
setCfg(next);
|
|
SaveRowColors(next as any).catch(() => {});
|
|
};
|
|
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
|
|
if (!cfg) return;
|
|
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
|
|
};
|
|
|
|
// The rule card shows the row exactly as the grid will draw it, so the choice
|
|
// is made by looking rather than by imagining.
|
|
const preview = (color: string): Record<string, string> => {
|
|
const st = cfg?.style ?? 'bar';
|
|
const pct = cfg?.intensity ?? 12;
|
|
const out: Record<string, string> = {};
|
|
if (st === 'tint' || st === 'both') out.backgroundColor = `color-mix(in srgb, ${color} ${pct}%, transparent)`;
|
|
if (st === 'bar' || st === 'both') out.boxShadow = `inset 3px 0 0 ${color}`;
|
|
return out;
|
|
};
|
|
|
|
if (!cfg) return <div className="p-1 text-sm text-muted-foreground">…</div>;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={!!cfg.bandmap_lotw} className="mt-0.5"
|
|
onCheckedChange={(c) => save({ ...cfg, bandmap_lotw: !!c } as any)} />
|
|
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
|
|
</label>
|
|
|
|
{/* Banding is its own thing, above the QSL rules and outside them: it says
|
|
nothing about a contact, it only helps the eye keep its line across a
|
|
wide table. Turning the status colours off must not take it away. */}
|
|
<div className="space-y-2">
|
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={(cfg as any).recent_zebra !== 'off'} className="mt-0.5"
|
|
onCheckedChange={(c) => save({ ...cfg, recent_zebra: c ? '' : 'off' } as any)} />
|
|
<span>{t('appr.zebra')} <span className="text-xs text-muted-foreground">{t('appr.zebraHint')}</span></span>
|
|
</label>
|
|
{(cfg as any).recent_zebra !== 'off' && (
|
|
<div className="flex items-center gap-2 pl-6">
|
|
<span className="text-xs text-muted-foreground">{t('appr.zebraColor')}</span>
|
|
<input type="color" className="size-7 rounded border border-border bg-transparent p-0 cursor-pointer"
|
|
value={/^#[0-9a-fA-F]{6}$/.test((cfg as any).recent_zebra_color ?? '') ? (cfg as any).recent_zebra_color : '#808080'}
|
|
onChange={(e) => save({ ...cfg, recent_zebra_color: e.target.value } as any)} />
|
|
{/* Empty means "follow the theme", and there has to be a way back to
|
|
it — a colour chosen under one theme is wrong under the other. */}
|
|
{(cfg as any).recent_zebra_color && (
|
|
<button type="button" className="text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => save({ ...cfg, recent_zebra_color: '' } as any)}>{t('appr.zebraAuto')}</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={cfg.enabled} className="mt-0.5"
|
|
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
|
<span>{t('appr.enable')} <span className="text-xs text-muted-foreground">{t('appr.enableHint')}</span></span>
|
|
</label>
|
|
|
|
{cfg.enabled && (
|
|
<div className="space-y-3">
|
|
{/* Style first: it decides whether the colours below are a signal or a
|
|
wallpaper, which matters more than which hue they are. */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<span className="text-sm">{t('appr.style')}</span>
|
|
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
|
{(['bar', 'tint', 'both'] as const).map((v) => (
|
|
<button key={v} type="button" onClick={() => save({ ...cfg, style: v })}
|
|
className={cn('px-3 py-1.5 font-medium', (cfg.style ?? 'bar') === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
|
{t('appr.style' + v[0].toUpperCase() + v.slice(1))}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{(cfg.style ?? 'bar') !== 'bar' && (
|
|
<label className="flex items-center gap-2 text-sm">
|
|
{t('appr.intensity')}
|
|
<input type="range" min={5} max={45} step={1} value={cfg.intensity ?? 12}
|
|
onChange={(e) => save({ ...cfg, intensity: parseInt(e.target.value, 10) })}
|
|
className="w-32 accent-[var(--primary)]" />
|
|
<span className="font-mono text-xs text-muted-foreground w-8">{cfg.intensity ?? 12}%</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
{/* Order matters and is shown: a contact is usually several of these at
|
|
once, and the first match wins. */}
|
|
<p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p>
|
|
{cfg.rules.map((r, i) => (
|
|
<div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2"
|
|
style={r.enabled ? preview(r.color) : undefined}>
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} />
|
|
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
|
|
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
|
|
</label>
|
|
{/* Which channels this category looks at. None ticked = all of
|
|
them, which is what an unnarrowed rule should mean. */}
|
|
{r.enabled && r.id !== 'worked' && (
|
|
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
|
|
{CHANNELS.map((c) => {
|
|
const on = !r.channels?.length || r.channels.includes(c);
|
|
return (
|
|
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
|
|
<Checkbox checked={on} onCheckedChange={(v) => {
|
|
const cur = r.channels?.length ? r.channels : [...CHANNELS];
|
|
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
|
|
patchRule(r.id, { channels: next });
|
|
}} />
|
|
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{r.enabled && (
|
|
<div className="flex items-center gap-1.5 flex-wrap pl-6">
|
|
{PALETTE.map((c) => (
|
|
<button key={c} type="button" title={c}
|
|
onClick={() => patchRule(r.id, { color: c })}
|
|
className={cn('size-6 rounded-md border-2 transition-transform hover:scale-110',
|
|
r.color.toLowerCase() === c ? 'border-foreground' : 'border-transparent')}
|
|
style={{ backgroundColor: c }} />
|
|
))}
|
|
<input type="color" value={r.color} title={t('appr.custom')}
|
|
onChange={(e) => patchRule(r.id, { color: e.target.value })}
|
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<MatrixColorsSection />
|
|
<WidgetOrderSection />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The row of widgets to the right of the entry, in the order they appear.
|
|
//
|
|
// Flexbox does the moving in the main view — this list only decides the order
|
|
// property each one gets. That is why a widget switched OFF still holds its
|
|
// place here: it comes back where the operator left it rather than at the end.
|
|
export const WIDGET_KEYS = [
|
|
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
|
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
|
|
] as const;
|
|
|
|
const WIDGET_LABELS: Record<string, string> = {
|
|
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
|
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
|
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
|
|
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
|
};
|
|
|
|
function readWidgetOrder(): string[] {
|
|
try {
|
|
const raw = localStorage.getItem('opslog.widgetOrder');
|
|
const arr = raw ? JSON.parse(raw) : null;
|
|
if (Array.isArray(arr)) {
|
|
// A key from an older build that no longer exists is dropped; a widget
|
|
// added since joins the end. An old preference can never hide a new one.
|
|
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
|
|
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
|
|
}
|
|
} catch { /* corrupt pref → the default order */ }
|
|
return [...WIDGET_KEYS];
|
|
}
|
|
|
|
function WidgetOrderSection() {
|
|
const { t } = useI18n();
|
|
const [order, setOrder] = useState<string[]>(readWidgetOrder);
|
|
const dragKey = useRef<string | null>(null);
|
|
const [dragging, setDragging] = useState<string | null>(null);
|
|
// Where the row would land. Drawn as a line above the target rather than by
|
|
// colouring it: the question a dragging hand asks is "between which two", and
|
|
// a highlighted row answers a different one.
|
|
const [over, setOver] = useState<string | null>(null);
|
|
|
|
const commit = (keys: string[]) => {
|
|
setOrder(keys);
|
|
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
|
|
// The main view listens: an order is meant to be watched as it is dragged,
|
|
// not discovered after closing Preferences.
|
|
EventsEmit('widgets:order', keys);
|
|
};
|
|
const moveTo = (from: string, to: string) => {
|
|
if (from === to) return;
|
|
const next = order.filter((k) => k !== from);
|
|
const at = next.indexOf(to);
|
|
next.splice(at < 0 ? next.length : at, 0, from);
|
|
commit(next);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
|
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
|
<div className="space-y-1 max-w-md">
|
|
{/* The two that cannot move, shown so the order reads as the whole row
|
|
rather than as a list that mysteriously starts at the third item. */}
|
|
{['wo.entry', 'wo.details'].map((k) => (
|
|
<div key={k}
|
|
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
|
<Lock className="size-3.5 shrink-0 opacity-60" />
|
|
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
|
</div>
|
|
))}
|
|
{order.map((k) => (
|
|
// The WHOLE row is the handle, not the grip alone: a list whose rows
|
|
// can only be moved by a 16-pixel icon is a list most people conclude
|
|
// cannot be moved. The grip stays as the sign that it can.
|
|
<div key={k} draggable
|
|
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
|
|
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
|
|
onDragOver={(e) => {
|
|
if (!dragKey.current) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
if (over !== k) setOver(k);
|
|
}}
|
|
onDragLeave={() => { if (over === k) setOver(null); }}
|
|
onDrop={(e) => {
|
|
if (!dragKey.current) return;
|
|
e.preventDefault();
|
|
moveTo(dragKey.current, k);
|
|
setOver(null);
|
|
}}
|
|
title={t('wo.drag')}
|
|
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
|
|
'cursor-grab active:cursor-grabbing transition-shadow',
|
|
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
|
|
// The landing line, on the edge the row would take.
|
|
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
|
|
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
|
|
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
|
className="text-xs text-muted-foreground hover:text-foreground underline">
|
|
{t('wo.reset')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|