This commit is contained in:
2026-06-13 19:14:24 +02:00
parent 0b3e22c97e
commit 81e505e040
19 changed files with 194 additions and 56 deletions
+18 -5
View File
@@ -28,6 +28,14 @@ export function AutoEQSL({ onSent, onError }: Props) {
const busy = useRef(false);
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
const svgEl = useRef<SVGSVGElement | null>(null);
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
// Keep the callbacks in refs so they never change the effects' identity — a
// toast/grid re-render from onSent must NOT re-run the send effect (that
// re-sent the same eQSL many times in a row).
const onSentRef = useRef(onSent);
const onErrorRef = useRef(onError);
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
// Pull the next job, fetch its render model + assets, then mount it (the
// effect below rasterizes once the DOM has it).
@@ -41,14 +49,16 @@ export function AutoEQSL({ onSent, onError }: Props) {
const assets = await loadCardAssets(model.template, job.templateId);
setCurrent({ job, model, assets });
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
onErrorRef.current?.(`Auto eQSL: ${e}`);
busy.current = false;
void pump();
}
}, [onError]);
}, []);
useEffect(() => {
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
void pump();
});
@@ -56,20 +66,23 @@ export function AutoEQSL({ onSent, onError }: Props) {
}, [pump]);
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
// Sends exactly once per job (guarded by sentForRef), independent of renders.
useEffect(() => {
if (!current) return;
if (sentForRef.current === current.job.qsoId) return; // already sent this one
let cancelled = false;
void (async () => {
try {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
if (cancelled || !svgEl.current) return;
sentForRef.current = current.job.qsoId;
const card = current.model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
onSent?.(current.job.callsign);
onSentRef.current?.(current.job.callsign);
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
onErrorRef.current?.(`Auto eQSL: ${e}`);
} finally {
if (!cancelled) {
setCurrent(null);
@@ -79,7 +92,7 @@ export function AutoEQSL({ onSent, onError }: Props) {
}
})();
return () => { cancelled = true; };
}, [current, pump, onSent, onError]);
}, [current, pump]);
if (!current) return null;
// Off-screen at full card resolution so the rasterized output matches the
+5 -4
View File
@@ -72,10 +72,11 @@ function buildFxParams(e: CardElement): TextFxParams | null {
size: e.size,
space: e.size * (kind === 'western' ? 0.08 : 0.04),
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
// Dark inter-letter edge — fixed near-black (the navy outline adaptStyle
// sets is for the old SVG stack, not this look).
cDark: kind === 'western' ? '#1c130a' : '#262630',
cOuter: silver ? '#e8edf2' : '#ced3db',
// Dark inter-letter edge + rim — from the chosen colour palette, else the
// default near-black edge (the navy outline adaptStyle sets is for the old
// SVG stack, not this look).
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
// Per-call overrides from the editor (undefined → renderer default).
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
@@ -96,7 +96,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
async function choosePhotos() {
try {
const paths = ((await QSLPickPhotos()) ?? []) as string[];
if (paths.length) setPhotoPaths(paths.slice(0, 3));
if (paths.length) setPhotoPaths(paths.slice(0, 5));
} catch (e) {
setError(String(e));
}
@@ -254,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
<section className="space-y-2">
<h3 className="text-sm font-semibold">New design</h3>
<p className="text-xs text-muted-foreground">
Pick 13 photos OpsLog analyzes them and proposes three card designs
Pick 15 photos OpsLog analyzes them and proposes three card designs
with your callsign, name, zones and country placed automatically.
</p>
<div className="flex items-center gap-2">
@@ -75,7 +75,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogContent className="max-w-[820px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="size-5 text-rose-600" /> Send eQSL by e-mail
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
</DialogTitle>
</DialogHeader>
@@ -108,7 +108,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogFooter>
{sent ? (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckCircle2 className="size-4" /> eQSL sent.
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
</div>
) : (
@@ -16,6 +16,25 @@ interface Props {
onChange: (preset: string, params: StyleParams) => void;
}
// Quick colour palettes per FX family (mirrors the reference generators):
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
const GLOSSY_PALETTES: Palette[] = [
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
];
const WESTERN_PALETTES: Palette[] = [
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
];
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="flex items-center justify-between gap-2">
@@ -76,6 +95,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
</SelectContent>
</Select>
{isFx && (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Palette</Label>
<div className="flex flex-wrap gap-1">
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
<button
key={p.name} type="button" title={p.name}
className="h-6 w-6 rounded border border-border"
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
onClick={() => onChange(preset, {
...params,
gradient: [p.top, p.mid, p.bot],
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
})}
/>
))}
</div>
</div>
)}
{has('color') && (
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
)}
+2
View File
@@ -70,6 +70,8 @@ export interface FxParams {
grunge?: number;
bevel?: number;
seed?: number;
dark?: string;
outer?: string;
}
export interface StyleParams {