The dot is drawn over all five cell backgrounds, so it cannot borrow one of their colours; it defaults to the theme foreground with a background ring and now takes two overrides of its own, worked and confirmed, alongside the rest of the matrix palette.
296 lines
14 KiB
TypeScript
296 lines
14 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', '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 />
|
|
</div>
|
|
);
|
|
}
|