Files
OpsLog/frontend/src/components/qsl/QslDesignerModal.tsx
T
2026-06-13 01:34:45 +02:00

381 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 13 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>
);
}