The PH/CW/DIG grid in the Stats panel is the fastest read in the app and its palette was fixed per theme. Settings -> Appearance now offers the six: the four status fills, the never-worked fill, and the ring on the cell being entered. Stored as OVERRIDES, not as a palette. Each of the twelve themes ships an --mx-* ramp tuned to its own background, so an operator who only wants a different green must not thereby freeze the other four to the theme they happened to be using that day. An empty value means "whatever the theme says"; the chosen ones are stamped inline on <html>, where they win over every theme; switching the feature off hands the colours straight back. The pickers are seeded from what the matrix is painting at that moment rather than from a fixed palette, so the choice starts from the colours in front of the operator. A new --mx-cur token carries the current-entry ring: it follows --warning by default, so it stays theme-correct on all twelve, but can be recoloured without dragging every other warning in the app along. The legend under the matrix and its cell tooltips were hardcoded English. They now go through t() with the same keys as the pickers, so the grid and the settings cannot disagree about which green is which.
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
import { useEffect, useState } from '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'] as const;
|
|
const CHANNEL_LABELS: Record<string, string> = {
|
|
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
|
};
|
|
|
|
// 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" />
|
|
</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>
|
|
|
|
<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 />
|
|
</div>
|
|
);
|
|
}
|