290 lines
13 KiB
TypeScript
290 lines
13 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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|