381 lines
14 KiB
TypeScript
381 lines
14 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,
|
||
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 [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);
|
||
} 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<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-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–3 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>
|
||
|
||
<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-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}
|
||
onSelect={setSel}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|