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 = { 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 = { 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(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 (
{cfg.enabled && (
{MATRIX_VARS.map(({ key, cssVar, label }) => ( ))}
{/* The matrix as it will actually look: same tokens, same shapes. */}
{t('appr.matrixSample')}
)}
); } export function AppearancePanel() { const { t } = useI18n(); const [cfg, setCfg] = useState(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 => { const st = cfg?.style ?? 'bar'; const pct = cfg?.intensity ?? 12; const out: Record = {}; 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
; return (
{/* 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. */}
{(cfg as any).recent_zebra !== 'off' && (
{t('appr.zebraColor')} 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 && ( )}
)}
{cfg.enabled && (
{/* Style first: it decides whether the colours below are a signal or a wallpaper, which matters more than which hue they are. */}
{t('appr.style')}
{(['bar', 'tint', 'both'] as const).map((v) => ( ))}
{(cfg.style ?? 'bar') !== 'bar' && ( )}
{/* Order matters and is shown: a contact is usually several of these at once, and the first match wins. */}

{t('appr.orderHint')}

{cfg.rules.map((r, i) => (
{/* Which channels this category looks at. None ticked = all of them, which is what an unnarrowed rule should mean. */} {r.enabled && r.id !== 'worked' && (
{CHANNELS.map((c) => { const on = !r.channels?.length || r.channels.includes(c); return ( ); })}
)} {r.enabled && (
{PALETTE.map((c) => (
)}
))}
)}
); } // 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', 'dvk', 'winkeyer', 'photo', ] as const; const WIDGET_LABELS: Record = { 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', 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(readWidgetOrder); const dragKey = useRef(null); const [dragging, setDragging] = useState(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(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 (

{t('wo.title')}

{t('wo.hint')}

{/* 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) => (
{t(k)}
))} {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.
{ 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)]')}> {t(WIDGET_LABELS[k] ?? k)}
))}
); }