qsl designer
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
// 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 { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
||||
} from '../../../wailsjs/go/main/App';
|
||||
import { main } from '../../../wailsjs/go/models';
|
||||
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<RenderModel> {
|
||||
return JSON.parse(await QSLResolvePreview(JSON.stringify(template))) as RenderModel;
|
||||
}
|
||||
|
||||
export function QslDesignerModal({ open, onClose }: Props) {
|
||||
const [view, setView] = useState<View>('home');
|
||||
const [photoPaths, setPhotoPaths] = useState<string[]>([]);
|
||||
const [proposals, setProposals] = useState<Proposal[]>([]);
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const [sel, setSel] = useState<CardSelection>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [asDefault, setAsDefault] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||
const [deleteArm, setDeleteArm] = useState(0);
|
||||
const [mailSubject, setMailSubject] = useState('');
|
||||
const [mailBody, setMailBody] = useState('');
|
||||
const [mailSaved, setMailSaved] = useState(false);
|
||||
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setView('home');
|
||||
setError('');
|
||||
setPhotoPaths([]);
|
||||
setProposals([]);
|
||||
setEditing(null);
|
||||
void refreshSaved();
|
||||
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
|
||||
void QSLGetEmailTemplates().then((t) => { setMailSubject(t.subject); setMailBody(t.body); setMailSaved(false); });
|
||||
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<number, string> = {};
|
||||
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, 6));
|
||||
} 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<CardElement>) =>
|
||||
patchBoth((t) => Object.assign(t.elements[idx], patch), 'text' in patch);
|
||||
|
||||
const patchBox = (patch: Partial<QSOBox>) =>
|
||||
patchBoth((t) => { if (t.qso_box) Object.assign(t.qso_box, patch); }, 'footer' in patch || 'title' in patch);
|
||||
|
||||
const onMove = (target: Exclude<CardSelection, null>, 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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[1180px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-amber-500" />
|
||||
QSL card designer
|
||||
{view !== 'home' && (
|
||||
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
||||
onClick={() => setView(view === 'editor' && proposals.length && editing?.templateId === 0 ? 'proposals' : 'home')}>
|
||||
<ChevronLeft className="size-3.5" /> Back
|
||||
</Button>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{view === 'home' && (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">New design</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick 1–6 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">
|
||||
<Button variant="outline" size="sm" onClick={choosePhotos}>
|
||||
<Images className="mr-1 size-4" /> Choose photos…
|
||||
</Button>
|
||||
<Button size="sm" disabled={!photoPaths.length || busy} onClick={generate}>
|
||||
{busy ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
|
||||
Generate designs
|
||||
</Button>
|
||||
{photoPaths.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{photoPaths.length} photo{photoPaths.length > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{saved.map((t) => (
|
||||
<div key={t.id} className="rounded-md border border-border p-2">
|
||||
{previews[t.id]
|
||||
? <img src={previews[t.id]} alt={t.name} className="w-full rounded" />
|
||||
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-0.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Edit"
|
||||
onClick={() => editSaved(t)}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
{!t.is_default && (
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Set as default"
|
||||
onClick={() => makeDefault(t.id)}>
|
||||
<Star className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm"
|
||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
|
||||
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">eQSL e-mail message</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'} fill in per QSO.
|
||||
</p>
|
||||
<Input className="h-8" value={mailSubject} placeholder="Subject"
|
||||
onChange={(e) => { setMailSubject(e.target.value); setMailSaved(false); }} />
|
||||
<Textarea rows={3} value={mailBody} placeholder="Body"
|
||||
onChange={(e) => { setMailBody(e.target.value); setMailSaved(false); }} />
|
||||
<Button variant="outline" size="sm" disabled={mailSaved}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await QSLSaveEmailTemplates(new main.QSLEmailTemplates({ subject: mailSubject, body: mailBody }));
|
||||
setMailSaved(true);
|
||||
} catch (e) { setError(String(e)); }
|
||||
}}>
|
||||
{mailSaved ? 'Saved' : 'Save message'}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'proposals' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Pick a design to refine it:</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{proposals.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
|
||||
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||
>
|
||||
<CardPreview model={p.model} assets={p.assets} width={352} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'editor' && editing && (
|
||||
<div className="flex gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="rounded-md bg-slate-800/60 p-3">
|
||||
<CardPreview
|
||||
model={editing.model}
|
||||
assets={editing.assets}
|
||||
width={760}
|
||||
selected={sel}
|
||||
onSelect={setSel}
|
||||
onMove={onMove}
|
||||
svgRef={(el) => { svgEl.current = el; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input className="h-8 w-56" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Vietnam sunset" />
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox checked={asDefault} onCheckedChange={(v) => setAsDefault(v === true)} />
|
||||
Default for this profile
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" disabled={busy || !name.trim()} onClick={save}>
|
||||
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Save template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EditorPanel
|
||||
template={editing.template}
|
||||
sel={sel}
|
||||
presets={presets}
|
||||
fontFamilies={fontFamilies}
|
||||
onPatchElement={patchElement}
|
||||
onPatchBox={patchBox}
|
||||
onScrim={onScrim}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user