up
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 1–3 photos — OpsLog analyzes them and proposes three card designs
|
||||
Pick 1–5 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 })} />
|
||||
)}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface FxParams {
|
||||
grunge?: number;
|
||||
bevel?: number;
|
||||
seed?: number;
|
||||
dark?: string;
|
||||
outer?: string;
|
||||
}
|
||||
|
||||
export interface StyleParams {
|
||||
|
||||
Reference in New Issue
Block a user