A second profile is usually the same operator with a different rig or a different locator — same callsign, same cards. Having to redraw a design because the antenna changed is work nobody should do. The designer now lists the designs the active profile cannot see and copies a chosen one into it. Designs it already sees — its own, and the shared ones — are left out: they are not something to copy. A COPY, not a move or a share. The original profile keeps its design untouched and the duplicate is free to diverge, which it usually will: a second profile exists because something differs, and that something often ends up on the card. The PICTURES are copied too, and that is the part worth getting right. Stored documents reference photos by name relative to the template's own asset folder, so duplicating the JSON alone would point the new design at files that are not in its folder. It is validated against its own folder afterwards, and a failure rolls the row back rather than leaving a design whose pictures are missing. Sharing the source's folder was the other option and a worse one: deleting either design would then have emptied the other.
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
// 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,
|
||
QSLListOtherProfileTemplates, QSLCopyTemplateToActiveProfile,
|
||
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<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 [foreign, setForeign] = useState<any[]>([]);
|
||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||
const [deleteArm, setDeleteArm] = useState(0);
|
||
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 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);
|
||
// The designs this profile cannot see, offered for copying.
|
||
try { setForeign(((await QSLListOtherProfileTemplates()) ?? []) as any[]); } catch { /* nothing to offer */ }
|
||
} catch (e) {
|
||
setError(String(e));
|
||
}
|
||
}
|
||
|
||
async function choosePhotos() {
|
||
try {
|
||
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
||
if (paths.length) setPhotoPaths(paths.slice(0, 5));
|
||
} 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-[1260px]">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Sparkles className="size-5 text-warning" />
|
||
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-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{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–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">
|
||
<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">
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||
{/* Designs owned by another profile. A second profile is usually
|
||
the same operator with a different rig or locator — same
|
||
callsign, same cards — and redrawing one because the antenna
|
||
changed is work nobody should have to do. */}
|
||
{!!foreign.length && (
|
||
<select
|
||
value=""
|
||
onChange={async (e) => {
|
||
const id = parseInt(e.target.value, 10);
|
||
if (!id) return;
|
||
try { await QSLCopyTemplateToActiveProfile(id); await refreshSaved(); }
|
||
catch (err) { setError(String(err)); }
|
||
}}
|
||
className="h-7 rounded-md border border-border bg-background px-2 text-xs">
|
||
<option value="">Copy from another profile…</option>
|
||
{foreign.map((f) => (
|
||
<option key={f.id} value={f.id}>{f.profile_name} — {f.name}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
{!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-warning text-warning" />}
|
||
{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-destructive' : '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>
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
The eQSL e-mail message and the auto-send option are in Settings → E-mail (SMTP).
|
||
</p>
|
||
</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-info"
|
||
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}
|
||
onSelect={setSel}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|