// QslDesignerModal — the QSL card designer. Flow: drop/choose photos → // "Generate designs" (3 automatic proposals from the placement engine) → // pick one → live editor (click/drag elements, style controls) → save. // Saved templates are listed with thumbnails and can be edited, deleted or // marked default (used by "Send eQSL by e-mail" on the QSO grid). import { useEffect, useRef, useState } from 'react'; import { Images, Loader2, Sparkles, Star, Trash2, Pencil, ChevronLeft } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate, QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview, QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets, } from '../../../wailsjs/go/main/App'; import type { CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo, } from './qslTypes'; import { loadCardAssets, loadFonts, type CardAssets } from './qslAssets'; import { CardPreview, type CardSelection } from './CardPreview'; import { EditorPanel } from './EditorPanel'; import { rasterizeCard } from './rasterize'; interface Props { open: boolean; onClose: () => void; } interface Proposal { template: CardTemplate; model: RenderModel; assets: CardAssets; } interface Editing { templateId: number; // 0 = new (photos still at their original paths) template: CardTemplate; // raw document ({placeholders}) model: RenderModel; // resolved copy for display assets: CardAssets; } type View = 'home' | 'proposals' | 'editor'; async function resolveModel(template: CardTemplate): Promise { return JSON.parse(await QSLResolvePreview(JSON.stringify(template))) as RenderModel; } export function QslDesignerModal({ open, onClose }: Props) { const [view, setView] = useState('home'); const [photoPaths, setPhotoPaths] = useState([]); const [proposals, setProposals] = useState([]); const [editing, setEditing] = useState(null); const [sel, setSel] = useState(null); const [name, setName] = useState(''); const [asDefault, setAsDefault] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [saved, setSaved] = useState([]); const [previews, setPreviews] = useState>({}); const [presets, setPresets] = useState([]); const [fontFamilies, setFontFamilies] = useState([]); const [deleteArm, setDeleteArm] = useState(0); const svgEl = useRef(null); useEffect(() => { if (!open) return; setView('home'); setError(''); setPhotoPaths([]); setProposals([]); setEditing(null); void refreshSaved(); void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[])); void loadFonts().then(({ fonts }) => setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans'])); }, [open]); async function refreshSaved() { try { const list = ((await QSLListTemplates()) ?? []) as QSLTemplateInfo[]; setSaved(list); const p: Record = {}; await Promise.all(list.map(async (t) => { const url = await QSLPreviewDataURL(t.id); if (url) p[t.id] = url; })); setPreviews(p); } catch (e) { setError(String(e)); } } async function choosePhotos() { try { const paths = ((await QSLPickPhotos()) ?? []) as string[]; if (paths.length) setPhotoPaths(paths.slice(0, 3)); } catch (e) { setError(String(e)); } } async function generate() { setBusy(true); setError(''); try { const docs = (await QSLGenerateProposals(photoPaths)) as string[]; const out: Proposal[] = []; for (const doc of docs) { const template = JSON.parse(doc) as CardTemplate; const model = await resolveModel(template); const assets = await loadCardAssets(model.template, 0); out.push({ template, model, assets }); } setProposals(out); setView('proposals'); } catch (e) { setError(String(e)); } finally { setBusy(false); } } function openEditor(templateId: number, template: CardTemplate, model: RenderModel, assets: CardAssets) { setEditing({ templateId, template, model, assets }); setName(template.name); setAsDefault(false); setSel(null); setError(''); setView('editor'); } async function editSaved(info: QSLTemplateInfo) { setBusy(true); setError(''); try { const template = JSON.parse(await QSLGetTemplate(info.id)) as CardTemplate; const model = await resolveModel(template); const assets = await loadCardAssets(model.template, info.id); openEditor(info.id, template, model, assets); setAsDefault(info.is_default); } catch (e) { setError(String(e)); } finally { setBusy(false); } } // Edits patch the raw template AND the resolved display copy in lock-step; // only a text change needs a backend re-resolve (placeholders may differ). function patchBoth(fn: (t: CardTemplate) => void, reResolve = false) { setEditing((cur) => { if (!cur) return cur; const template = structuredClone(cur.template); const model = structuredClone(cur.model); fn(template); fn(model.template); if (reResolve) { void resolveModel(template).then((m) => setEditing((c) => (c ? { ...c, model: m } : c))); } return { ...cur, template, model }; }); } const patchElement = (idx: number, patch: Partial) => patchBoth((t) => Object.assign(t.elements[idx], patch), 'text' in patch); const patchBox = (patch: Partial) => patchBoth((t) => { if (t.qso_box) Object.assign(t.qso_box, patch); }, 'footer' in patch || 'title' in patch); const onMove = (target: Exclude, x: number, y: number) => patchBoth((t) => { if (target === 'box') { if (t.qso_box) { t.qso_box.x = x; t.qso_box.y = y; } } else { t.elements[target].x = x; t.elements[target].y = y; } }); const onScrim = (enabled: boolean) => patchBoth((t) => { t.hero.scrim = { enabled, color: t.hero.scrim?.color ?? '#0b1a2e', opacity: t.hero.scrim?.opacity ?? 0.25 }; }); async function save() { if (!editing) return; setBusy(true); setError(''); try { const id = await QSLSaveTemplate(editing.templateId, name, JSON.stringify(editing.template), true); if (svgEl.current) { const card = editing.template.card; const png = await rasterizeCard(svgEl.current, 480, Math.round(480 * (card.h / card.w)), 'image/png'); await QSLSavePreview(id, png); } if (asDefault) await QSLSetDefaultTemplate(id); await refreshSaved(); setView('home'); setEditing(null); } catch (e) { setError(String(e)); } finally { setBusy(false); } } async function removeTemplate(id: number) { if (deleteArm !== id) { setDeleteArm(id); return; } setDeleteArm(0); try { await QSLDeleteTemplate(id); await refreshSaved(); } catch (e) { setError(String(e)); } } async function makeDefault(id: number) { try { await QSLSetDefaultTemplate(id); await refreshSaved(); } catch (e) { setError(String(e)); } } return ( !v && onClose()}> QSL card designer {view !== 'home' && ( )}
{error && (
{error}
)} {view === 'home' && (

New design

Pick 1–3 photos — OpsLog analyzes them and proposes three card designs with your callsign, name, zones and country placed automatically.

{photoPaths.length > 0 && ( {photoPaths.length} photo{photoPaths.length > 1 ? 's' : ''} selected )}

Saved templates

{!saved.length &&

None yet.

}
{saved.map((t) => (
{previews[t.id] ? {t.name} :
no preview
}
{t.is_default && } {t.name} {!t.is_default && ( )}
))}

The eQSL e-mail message and the auto-send option are in Settings → E-mail (SMTP).

)} {view === 'proposals' && (

Pick a design to refine it:

{proposals.map((p, i) => ( ))}
)} {view === 'editor' && editing && (
{ svgEl.current = el; }} />
setName(e.target.value)} placeholder="e.g. Vietnam sunset" />
)}
); }