qsl designer

This commit is contained in:
2026-06-11 21:54:35 +02:00
parent 6150498a9e
commit 408b29896c
252 changed files with 13989 additions and 277 deletions
+14 -5
View File
@@ -121,12 +121,13 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 9000; // lobe length (km)
const D = 5500; // lobe length (km) — short enough to rarely reach a pole
const radial = (b: number): [number, number][] =>
Array.from({ length: 14 }, (_, i) => {
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
return [d.lat, d.lon] as [number, number];
});
const edge = { color: '#dc2626', weight: 1.5, opacity: 0.6 };
for (const az of beamAzimuths) {
const arc: [number, number][] = [];
for (let b = az - half; b <= az + half + 0.001; b += 2) {
@@ -139,10 +140,18 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
...arc,
...radial(az + half).reverse(),
]);
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
// Boresight (dashed centre line).
// A geodesic lobe that reaches near a pole can't be filled on a
// Mercator map without the polygon snapping across the whole world —
// draw just the two edges in that case; otherwise the translucent lobe.
if (ring.some(([la]) => Math.abs(la) > 82)) {
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az - half)]) as L.LatLngExpression[], edge).addTo(wo);
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az + half)]) as L.LatLngExpression[], edge).addTo(wo);
} else {
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
}
// Boresight (dashed centre line) — always; great-circle polyline is safe.
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
+21 -9
View File
@@ -11,6 +11,7 @@ type Props = {
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
};
@@ -24,7 +25,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
// Lightweight right-click menu for the QSO grids. AG Grid's native context
// menu is an Enterprise feature, so this is a plain floating menu driven by
// onCellContextMenu. Closes on any outside click, scroll or Escape.
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
useEffect(() => {
if (!menu) return;
const close = () => onClose();
@@ -80,16 +81,27 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
</button>
)}
{onSendRecording && (
{(onSendRecording || onSendEQSL) && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
>
<Mail className="size-4 text-rose-600" />
<span>Send recording by e-mail</span>
</button>
{onSendEQSL && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
>
<Mail className="size-4 text-amber-600" />
<span>Send eQSL by e-mail</span>
</button>
)}
{onSendRecording && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
>
<Mail className="size-4 text-rose-600" />
<span>Send recording by e-mail</span>
</button>
)}
</>
)}
+3 -1
View File
@@ -52,6 +52,7 @@ type Props = {
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
};
@@ -215,7 +216,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -361,6 +362,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered}
/>
+88 -56
View File
@@ -15,6 +15,7 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetSecretStatus, SetPassphrase, RemovePassphrase,
GetEmailSettings, SaveEmailSettings, TestEmail,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
@@ -113,7 +114,7 @@ const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
const emptyProfile = (): Profile => ({
id: 0,
name: '',
callsign: '', operator: '', owner_callsign: '',
callsign: '', operator: '', op_name: '', owner_callsign: '',
my_grid: '', my_country: '',
my_state: '', my_cnty: '',
my_street: '', my_city: '', my_postal_code: '',
@@ -426,7 +427,29 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
const [ppNew, setPpNew] = useState('');
const [ppConfirm, setPpConfirm] = useState('');
const [ppErr, setPpErr] = useState('');
const [ppBusy, setPpBusy] = useState(false);
const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} };
useEffect(() => { refreshSecret(); }, []);
const applyPassphrase = async () => {
if (ppNew !== ppConfirm) { setPpErr('Passphrases do not match'); return; }
setPpBusy(true); setPpErr('');
try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
catch (e: any) { setPpErr(String(e?.message ?? e)); }
finally { setPpBusy(false); }
};
const removePassphrase = async () => {
setPpBusy(true); setPpErr('');
try { await RemovePassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
catch (e: any) { setPpErr(String(e?.message ?? e)); }
finally { setPpBusy(false); }
};
// E-mail / SMTP (send QSO recordings).
type EmailCfg = {
@@ -837,6 +860,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Operator name</Label>
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
<div className="text-[10px] text-muted-foreground">Your first name used as the signature on QSL cards.</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Owner callsign</Label>
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
@@ -1488,6 +1516,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Select>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={catModeBeforeFreq}
onCheckedChange={(c) => { const v = !!c; setCatModeBeforeFreq(v); writeUiPref('opslog.catModeBeforeFreq', v ? '1' : '0'); }}
/>
Set mode before frequency <span className="text-xs text-muted-foreground">(older rigs that drop the mode after a band change)</span>
</label>
<p className="text-xs text-muted-foreground">
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
OpsLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
@@ -2896,63 +2931,60 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
function GeneralPanel() {
return (
<>
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
<div className="space-y-4 max-w-lg">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={autofocusWB}
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
Auto-focus "Worked before" for stations already worked
<span className="block text-xs text-muted-foreground mt-0.5">
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
</span>
</span>
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
<div className="space-y-3 max-w-lg">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
Auto-focus "Worked before" for known stations
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
Show the antenna beam heading on the Main map
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
QSO start time = end time <span className="text-xs text-muted-foreground">(matches LoTW when you call a while)</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={showBeamMap}
onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
Show the antenna beam heading on the Main map
<span className="block text-xs text-muted-foreground mt-0.5">
Draws the beam lobe at the rotor heading (and the opposite/both directions when an Ultrabeam is reversed or bidirectional). Turn off to hide it.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={startEqEnd}
onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
QSO start time = end time (log at completion)
<span className="block text-xs text-muted-foreground mt-0.5">
Sets TIME_ON equal to TIME_OFF the moment you log the QSO instead of when you first entered the call. Useful when you call a station for a while: the logged time then matches the other operator's, so LoTW/eQSL confirmations match.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={catModeBeforeFreq}
onCheckedChange={(c) => { const v = !!c; setCatModeBeforeFreq(v); writeUiPref('opslog.catModeBeforeFreq', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
Set CAT mode before frequency (older rigs)
<span className="block text-xs text-muted-foreground mt-0.5">
When clicking a spot, send the mode to the rig first, then the frequency. Some older transceivers drop the mode command if it arrives right after a band change, needing a second click. Both commands are also spaced out slightly to let the rig settle.
</span>
</span>
</label>
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
{secret.has_passphrase ? (
<>
<p className="text-xs text-muted-foreground">
Passwords encrypted{' '}
{secret.unlocked
? <span className="text-emerald-700 font-medium">— unlocked</span>
: <span className="text-amber-600 font-medium">— locked (unlock at launch)</span>}.
</p>
{secret.unlocked && (
<>
<div className="flex items-center gap-2 max-w-md">
<Input type="password" placeholder="New passphrase (to change)" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
<Button size="sm" disabled={!ppNew || ppBusy} onClick={applyPassphrase}>Change</Button>
</div>
<Button variant="outline" size="sm" disabled={ppBusy} onClick={removePassphrase}>Remove encryption (store passwords in clear)</Button>
</>
)}
</>
) : (
<>
<p className="text-xs text-muted-foreground">
Encrypt saved passwords with a passphrase (asked at launch). Stays portable — re-enter it on another PC.
</p>
<div className="flex items-center gap-2 max-w-md">
<Input type="password" placeholder="Passphrase" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
<Button size="sm" disabled={!ppNew || ppNew !== ppConfirm || ppBusy} onClick={applyPassphrase}>Encrypt</Button>
</div>
</>
)}
{ppErr && <div className="text-xs text-destructive">{ppErr}</div>}
</div>
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
+3 -1
View File
@@ -52,6 +52,7 @@ type Props = {
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
@@ -64,7 +65,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -238,6 +239,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
/>
{count > entries.length && (
+410
View File
@@ -0,0 +1,410 @@
// CardPreview — the single SVG rendering implementation for the QSL card.
// Proposals, the live editor, thumbnails and final rasterization ALL go
// through this component so the output can never drift from the preview.
// Style presets are layer-stack recipes (see internal/qslcard/presets.go and
// section 4 of the design spec); the gel stack renders the text 7 times,
// back to front: halo, drop shadow, outline, bevel base, bevel light edge,
// gradient face, wavy gloss band clipped to the glyphs.
import { useEffect, useRef, useState } from 'react';
import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
import { QSO_FIELD_LABELS } from './qslTypes';
import type { CardAssets } from './qslAssets';
import { FONT_WEIGHTS } from './qslAssets';
// An editor selection: an element index or the QSO box.
export type CardSelection = number | 'box' | null;
interface Props {
model: RenderModel;
assets: CardAssets;
width: number; // display width in CSS px
selected?: CardSelection;
onSelect?: (sel: CardSelection) => void;
onMove?: (sel: Exclude<CardSelection, null>, x: number, y: number) => void;
svgRef?: (el: SVGSVGElement | null) => void;
}
// Fallbacks mirroring the preset defaults in presets.go, applied when a
// document omits a knob the stack needs.
const GOLD = ['#FFD83A', '#FFC312', '#EE9400'];
const DEF_SHINE = { coverage: 0.5, opacity: 0.95 };
const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 };
const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 };
const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' };
// fontSpec maps a template font name to a concrete family + weight.
// "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed.
function fontSpec(font?: string): { family: string; weight: number | 'normal' } {
if (!font || font === 'system-bold-sans') {
return { family: "'Segoe UI', Arial, sans-serif", weight: 800 };
}
return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' };
}
function elementTransform(e: CardElement): string {
const r = e.rotate ? ` rotate(${e.rotate})` : '';
return `translate(${e.x} ${e.y})${r}`;
}
// Shared <text> layer of a style stack. All layers must carry identical
// glyph geometry — only paint/transform/filter differ.
function Layer({
e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder,
}: {
e: CardElement;
content: string;
fill: string;
stroke?: string;
strokeWidth?: number;
transform?: string;
filter?: string;
opacity?: number;
paintOrder?: 'stroke';
}) {
const f = fontSpec(e.font);
return (
<text
x={0}
y={0}
fontFamily={f.family}
fontWeight={f.weight}
fontSize={e.size}
dominantBaseline="text-before-edge"
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinejoin="round"
paintOrder={paintOrder}
transform={transform}
filter={filter}
opacity={opacity}
style={{ userSelect: 'none' }}
>
{content}
</text>
);
}
// glossWavePath builds the gel highlight band: full glyph width, bottom
// edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period
// ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line
// is what produces the "gel" look; the band is clipped to the glyphs.
function glossWavePath(e: CardElement, content: string, coverage: number): string {
const size = e.size ?? 100;
const w = 0.78 * size * Math.max(content.length, 1);
const capTop = 0.16 * size; // cap height starts below the em-box top
const bottom = capTop + coverage * 0.72 * size;
const amp = 0.045 * size;
const period = 0.25 * size;
let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`;
let i = 0;
for (let x = w; x > 0.1; x -= period, i++) {
const x2 = Math.max(0, x - period);
const mid = (x + x2) / 2;
const dy = i % 2 === 0 ? amp : -amp;
d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`;
}
return d + ' Z';
}
// TextStack renders one text element through its style preset.
function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) {
const p: StyleParams = e.style_params ?? {};
const preset = e.style_preset ?? 'flat_modern';
const ow = p.outline_width ?? 0;
if (preset === 'flat_modern') {
return <Layer e={e} content={content} fill={p.color ?? '#ffffff'} />;
}
if (preset === 'script_white' || preset === 'outlined_white') {
return (
<>
{p.shadow && (
<Layer
e={e} content={content} fill={p.shadow.color} stroke={p.shadow.color}
strokeWidth={2} transform={`translate(${p.shadow.dx} ${p.shadow.dy})`}
filter={`url(#qsl-blur-sh-${idx})`} opacity={p.shadow.opacity}
/>
)}
<Layer
e={e} content={content} fill={p.color ?? '#ffffff'}
stroke={p.outline_color ?? '#22364e'} strokeWidth={ow || 2} paintOrder="stroke"
/>
</>
);
}
// Gel family + classic white outline: the layered stack.
const gradient = p.gradient ?? GOLD;
const shadow = p.shadow ?? DEF_SHADOW;
const outline = p.outline_color ?? '#2a3f5c';
const outlineW = ow || 9;
const isGel = preset === 'gel_gold' || preset === 'gel_silver';
const halo = p.halo ?? DEF_HALO;
const bevel = p.bevel_offset ?? DEF_BEVEL;
const shine = p.shine ?? DEF_SHINE;
const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined;
return (
<>
{isGel && (
<Layer
e={e} content={content} fill="none" stroke={halo.color}
strokeWidth={outlineW * 2.5} filter={`url(#qsl-blur-halo-${idx})`} opacity={halo.opacity}
/>
)}
<Layer
e={e} content={content} fill={shadow.color} stroke={shadow.color} strokeWidth={2}
transform={`translate(${shadow.dx} ${shadow.dy})`}
filter={`url(#qsl-blur-sh-${idx})`} opacity={shadow.opacity}
/>
<Layer
e={e} content={content} fill={outline} stroke={outline}
strokeWidth={outlineW} paintOrder="stroke"
/>
{isGel && <Layer e={e} content={content} fill={bevel.dark} />}
{isGel && (
<Layer
e={e} content={content} fill={bevel.light}
transform={`translate(${2 * bevel.dx} ${2 * bevel.dy})`}
/>
)}
<Layer e={e} content={content} fill={`url(#qsl-grad-${idx})`} transform={faceT} />
{isGel && (
<path
d={glossWavePath(e, content, shine.coverage)}
fill={`url(#qsl-gloss-${idx})`}
clipPath={`url(#qsl-clip-${idx})`}
transform={faceT}
opacity={shine.opacity}
/>
)}
{/* defs that belong to this stack (unique ids per element index) */}
<defs>
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
{gradient.map((c, i) => (
<stop key={i} offset={`${(i / Math.max(gradient.length - 1, 1)) * 100}%`} stopColor={c} />
))}
</linearGradient>
{isGel && (
<>
<linearGradient id={`qsl-gloss-${idx}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.05" />
</linearGradient>
<clipPath id={`qsl-clip-${idx}`}>
<text
x={0} y={0} fontFamily={fontSpec(e.font).family} fontWeight={fontSpec(e.font).weight}
fontSize={e.size} dominantBaseline="text-before-edge"
>
{content}
</text>
</clipPath>
<filter id={`qsl-blur-halo-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={halo.blur} />
</filter>
</>
)}
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={shadow.blur} />
</filter>
</defs>
</>
);
}
// QSOBoxView renders the confirmation box with per-QSO values.
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
const colW = (box.w - 56) / Math.max(box.fields.length, 1);
return (
<>
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{box.title}
</text>
{box.fields.map((f, i) => (
<g key={f} transform={`translate(${28 + i * colW} ${box.h * 0.42})`}>
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
</text>
<text y={26} fontSize={30} fontWeight={700} fill="#14243a"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{values[f] ?? ''}
</text>
</g>
))}
{box.footer && (
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
fontFamily="'Segoe UI', Arial, sans-serif">
{box.footer}
</text>
)}
</>
);
}
export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) {
const { template: t, qso_fields } = model;
const card = t.card;
const scale = card.w / width;
const rootRef = useRef<SVGSVGElement | null>(null);
const groupRefs = useRef(new Map<string, SVGGElement>());
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
// Measure the selected group for the dashed selection outline.
useEffect(() => {
if (selected === null || selected === undefined) { setSelBox(null); return; }
const key = String(selected);
const g = groupRefs.current.get(key);
if (!g) { setSelBox(null); return; }
const b = g.getBBox();
const transform = g.getAttribute('transform') ?? '';
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
}, [selected, model, assets, width]);
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
(ev: React.PointerEvent<SVGGElement>) => {
onSelect?.(sel);
if (!onMove) return;
ev.stopPropagation();
drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy };
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
};
const onPointerMove = (ev: React.PointerEvent<SVGSVGElement>) => {
const d = drag.current;
if (!d || !onMove) return;
const x = Math.round(d.ox + (ev.clientX - d.sx) * scale);
const y = Math.round(d.oy + (ev.clientY - d.sy) * scale);
onMove(d.sel, x, y);
};
const endDrag = () => { drag.current = null; };
const hero = assets.photos[t.hero.photo];
const crop = t.hero.crop;
const setGroupRef = (key: string) => (el: SVGGElement | null) => {
if (el) groupRefs.current.set(key, el);
else groupRefs.current.delete(key);
};
return (
<svg
ref={(el) => { rootRef.current = el; svgRef?.(el); }}
viewBox={`0 0 ${card.w} ${card.h}`}
width={width}
height={Math.round(width * (card.h / card.w))}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block', borderRadius: 4 }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerDown={() => onSelect?.(null)}
>
<defs>
{/* Fonts must live INSIDE the SVG so rasterization (SVG → <img> →
canvas) sees them; document styles don't apply there. */}
<style>{assets.fontCss}</style>
<filter id="qsl-insert-shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="6" dy="10" stdDeviation="8" floodColor="#0a1422" floodOpacity="0.45" />
</filter>
</defs>
{/* hero photo, source crop mapped onto the full card */}
<rect width={card.w} height={card.h} fill="#1a2433" />
{hero && crop.w > 0 && crop.h > 0 && (
<image
href={hero.url}
x={(-crop.x * card.w) / crop.w}
y={(-crop.y * card.h) / crop.h}
width={(hero.w * card.w) / crop.w}
height={(hero.h * card.h) / crop.h}
preserveAspectRatio="none"
/>
)}
{t.hero.scrim?.enabled && (
<rect width={card.w} height={card.h} fill={t.hero.scrim.color} opacity={t.hero.scrim.opacity} />
)}
{t.elements.map((e, idx) => {
const key = String(idx);
if (e.type === 'insert') {
const ph = assets.photos[e.photo ?? ''];
if (!ph || !e.w) return null;
const h = (e.w * ph.h) / ph.w;
const b = e.border_px ?? 0;
return (
<g
key={key} ref={setGroupRef(key)}
transform={`translate(${e.x} ${e.y})${e.rotate ? ` rotate(${e.rotate} ${e.w / 2} ${h / 2})` : ''}`}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<rect x={-b} y={-b} width={e.w + 2 * b} height={h + 2 * b} fill="#ffffff"
filter={e.shadow ? 'url(#qsl-insert-shadow)' : undefined} />
<image href={ph.url} width={e.w} height={h} preserveAspectRatio="none" />
</g>
);
}
if (e.type === 'country') {
const size = e.size ?? 30;
const flagH = size * 1.4;
const flagW = flagH * (4 / 3);
const hasFlag = !!assets.flagUrl;
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
{hasFlag && <image href={assets.flagUrl} width={flagW} height={flagH} />}
<text
x={hasFlag ? flagW + 16 : 0} y={flagH / 2}
fontSize={size} fontWeight={700} fontFamily="'Segoe UI', Arial, sans-serif"
fill="#ffffff" stroke="#22364e" strokeWidth={4} paintOrder="stroke"
strokeLinejoin="round" dominantBaseline="middle"
>
{e.label === 'auto' ? '' : e.label}
</text>
</g>
);
}
// text elements
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<TextStack e={e} idx={idx} content={e.text ?? ''} />
</g>
);
})}
{t.qso_box?.enabled && (
<g
ref={setGroupRef('box')}
transform={`translate(${t.qso_box.x} ${t.qso_box.y})`}
onPointerDown={startDrag('box', t.qso_box.x, t.qso_box.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<QSOBoxView box={t.qso_box} values={qso_fields} />
</g>
)}
{/* selection outline (editor only) */}
{selBox && onSelect && (
<g transform={selBox.t} pointerEvents="none">
<rect
x={selBox.x - 8} y={selBox.y - 8} width={selBox.w + 16} height={selBox.h + 16}
fill="none" stroke="#38bdf8" strokeWidth={3 * scale > 3 ? 3 * scale : 3}
strokeDasharray={`${10 * scale} ${6 * scale}`}
/>
</g>
)}
</svg>
);
}
+153
View File
@@ -0,0 +1,153 @@
// EditorPanel — the designer's right-hand controls: card-level options
// (scrim, name, default) and per-selection editing (font, size, rotation,
// style preset + params; insert width/border; QSO box geometry).
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker';
interface Props {
template: CardTemplate;
sel: CardSelection;
presets: QSLPresetInfo[];
fontFamilies: string[]; // embedded + detected system faces
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
onPatchBox: (patch: Partial<QSOBox>) => void;
onScrim: (enabled: boolean) => void;
}
const TYPE_LABELS: Record<string, string> = {
callsign: 'Callsign',
operator: 'Operator name',
info_line: 'Info line',
country: 'Country block',
insert: 'Photo insert',
};
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
onPatch: (idx: number, patch: Partial<CardElement>) => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={e.font} onValueChange={(font) => onPatch(idx, { font })}>
<SelectTrigger className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fontFamilies.map((f) => (
<SelectItem key={f} value={f}>{f === 'system-bold-sans' ? 'System bold' : f}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<NumberField label="Size" value={e.size ?? 0} min={8} max={500}
onChange={(size) => onPatch(idx, { size })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-45} max={45}
onChange={(rotate) => onPatch(idx, { rotate })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Text</Label>
<Input className="h-7 w-44 font-mono text-xs" value={e.text ?? ''}
onChange={(ev) => onPatch(idx, { text: ev.target.value })} />
</div>
<Separator className="my-2" />
<StylePresetPicker
presets={presets}
preset={e.style_preset ?? 'flat_modern'}
params={e.style_params ?? {}}
onChange={(style_preset, style_params: StyleParams) =>
onPatch(idx, { style_preset, style_params })}
/>
</div>
);
}
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim }: Props) {
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
const box = template.qso_box;
return (
<div className="flex w-72 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={!!template.hero.scrim?.enabled}
onCheckedChange={(v) => onScrim(v === true)}
/>
Darken photo behind text (scrim)
</label>
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
</div>
{!e && sel !== 'box' && (
<p className="text-xs text-muted-foreground">
Click an element on the card to edit it; drag to move it.
</p>
)}
{e && (e.type === 'callsign' || e.type === 'operator' || e.type === 'info_line') && (
<TextControls e={e} idx={sel as number} presets={presets}
fontFamilies={fontFamilies} onPatch={onPatchElement} />
)}
{e && e.type === 'insert' && (
<div className="space-y-2">
<NumberField label="Width" value={e.w ?? 0} min={80} max={1200}
onChange={(w) => onPatchElement(sel as number, { w })} />
<NumberField label="Border" value={e.border_px ?? 0} min={0} max={40}
onChange={(border_px) => onPatchElement(sel as number, { border_px })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-15} max={15}
onChange={(rotate) => onPatchElement(sel as number, { rotate })} />
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={!!e.shadow}
onCheckedChange={(v) => onPatchElement(sel as number, { shadow: v === true })} />
Drop shadow
</label>
</div>
)}
{e && e.type === 'country' && (
<NumberField label="Size" value={e.size ?? 30} min={16} max={80}
onChange={(size) => onPatchElement(sel as number, { size })} />
)}
{sel === 'box' && box && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={box.enabled}
onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
Show QSO box
</label>
<NumberField label="Width" value={box.w} min={300} max={1400}
onChange={(w) => onPatchBox({ w })} />
<NumberField label="Height" value={box.h} min={120} max={500}
onChange={(h) => onPatchBox({ h })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
onChange={(ev) => onPatchBox({ footer: ev.target.value })} />
</div>
</div>
)}
</div>
<p className="px-1 text-[11px] leading-snug text-muted-foreground">
Text supports {'{profile.*}'} and {'{qso.*}'} placeholders they fill in
automatically on every card.
</p>
</div>
);
}
@@ -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 16 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>
);
}
@@ -0,0 +1,123 @@
// SendEQSLModal — per-QSO eQSL flow: render the default template with the
// QSO's data, show the exact card, send it as a JPEG e-mail attachment on
// explicit confirmation (never automatic). On success the backend stamps
// eqsl_sent and emits "qsl:sent" so the grid refreshes.
import { useEffect, useRef, useState } from 'react';
import { Loader2, Mail, CheckCircle2 } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
QSLDefaultTemplateID, RenderEQSL, SendEQSL,
} from '../../../wailsjs/go/main/App';
import type { RenderModel } from './qslTypes';
import { loadCardAssets, type CardAssets } from './qslAssets';
import { CardPreview } from './CardPreview';
import { rasterizeCard } from './rasterize';
interface Props {
open: boolean;
qsoId: number | null;
onClose: () => void;
onOpenDesigner: () => void;
}
export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
const [model, setModel] = useState<RenderModel | null>(null);
const [assets, setAssets] = useState<CardAssets | null>(null);
const [templateId, setTemplateId] = useState(0);
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const svgEl = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (!open || !qsoId) return;
setModel(null);
setAssets(null);
setSent(false);
setError('');
void (async () => {
try {
const tid = await QSLDefaultTemplateID();
setTemplateId(tid);
if (!tid) return; // no template yet — offer the designer below
const m = JSON.parse(await RenderEQSL(qsoId, tid)) as RenderModel;
setModel(m);
setAssets(await loadCardAssets(m.template, tid));
} catch (e) {
setError(String(e));
}
})();
}, [open, qsoId]);
async function send() {
if (!qsoId || !model || !svgEl.current) return;
setBusy(true);
setError('');
try {
const card = model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(qsoId, templateId, jpeg);
setSent(true);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[820px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="size-5 text-rose-600" /> Send eQSL by e-mail
</DialogTitle>
</DialogHeader>
<div className="space-y-3 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>
)}
{open && !templateId && !error && (
<div className="space-y-3 py-2 text-sm">
<p>No QSL template yet design one first (drop a few photos, pick a layout).</p>
<Button size="sm" onClick={() => { onClose(); onOpenDesigner(); }}>Open the designer</Button>
</div>
)}
{templateId > 0 && !model && !error && (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Preparing the card
</div>
)}
{model && assets && (
<div className="rounded-md bg-slate-800/60 p-3">
<CardPreview model={model} assets={assets} width={740}
svgRef={(el) => { svgEl.current = el; }} />
</div>
)}
</div>
<DialogFooter>
{sent ? (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckCircle2 className="size-4" /> eQSL sent.
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
</div>
) : (
<>
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
<Button size="sm" disabled={!model || busy} onClick={send}>
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Send
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,137 @@
// StylePresetPicker — preset choice + per-preset parameter controls for the
// selected text element. Only the knobs the preset declares (params) are
// shown, matching the backend whitelist so saving can't fail on a stray key.
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { QSLPresetInfo, StyleParams } from './qslTypes';
interface Props {
presets: QSLPresetInfo[];
preset: string;
params: StyleParams;
onChange: (preset: string, params: StyleParams) => void;
}
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
<input
type="color"
className="h-7 w-10 cursor-pointer rounded border border-border bg-transparent"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
function SliderRow({ label, value, min, max, step, onChange }: {
label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void;
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="w-24 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="flex-1" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="w-8 text-right text-xs tabular-nums">{value}</span>
</div>
);
}
export function StylePresetPicker({ presets, preset, params, onChange }: Props) {
const info = presets.find((p) => p.name === preset);
const has = (k: string) => info?.params.includes(k) ?? false;
const set = (patch: Partial<StyleParams>) => onChange(preset, { ...params, ...patch });
return (
<div className="space-y-2">
<Select
value={preset}
onValueChange={(name) => {
const next = presets.find((p) => p.name === name);
// Switching presets resets the params to that preset's defaults —
// stale keys from the previous preset would be rejected on save.
onChange(name, next ? { ...next.defaults } : {});
}}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{presets.map((p) => (
<SelectItem key={p.name} value={p.name}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
{has('color') && (
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
)}
{has('gradient') && (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Gradient</Label>
<div className="flex gap-1">
{(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400']).map((c, i) => (
<input
key={i} type="color" value={c}
className="h-7 w-8 cursor-pointer rounded border border-border bg-transparent"
onChange={(e) => {
const g = [...(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400'])];
g[i] = e.target.value;
set({ gradient: g });
}}
/>
))}
</div>
</div>
)}
{has('outline_color') && (
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
onChange={(v) => set({ outline_color: v })} />
)}
{has('outline_width') && (
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
onChange={(v) => set({ outline_width: v })} />
)}
{has('shine') && (
<SliderRow label="Gloss" value={params.shine?.coverage ?? 0.5} min={0.2} max={0.8} step={0.05}
onChange={(v) => set({ shine: { coverage: v, opacity: params.shine?.opacity ?? 0.95 } })} />
)}
{has('halo') && (
<SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05}
onChange={(v) => set({
halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v },
})} />
)}
{has('shadow') && (
<SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05}
onChange={(v) => set({
shadow: {
dx: params.shadow?.dx ?? 5, dy: params.shadow?.dy ?? 8,
blur: params.shadow?.blur ?? 5, color: params.shadow?.color ?? '#14243a', opacity: v,
},
})} />
)}
</div>
);
}
// Tiny labelled numeric input shared by the designer's right panel.
export function NumberField({ label, value, onChange, min, max }: {
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number;
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number" className="h-7 w-20 text-right" value={value} min={min} max={max}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
/>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
// Asset loading for the QSL card renderer: designer fonts (registered as
// @font-face AND kept as CSS text so rasterization can inline them into the
// serialized SVG — an SVG drawn through an <img> cannot see document fonts),
// photos with their natural dimensions, and the country flag.
import { QSLFonts, QSLPhotoDataURL, QSLFlagDataURL } from '../../../wailsjs/go/main/App';
import type { CardTemplate, QSLFontInfo } from './qslTypes';
export interface PhotoAsset {
url: string; // data URL
w: number; // natural pixel size
h: number;
}
export interface CardAssets {
photos: Record<string, PhotoAsset>; // keyed by template photo ref
flagUrl: string; // data URL, '' when no flag
fontCss: string; // @font-face rules with data: sources
}
// Weight each embedded family renders at (variable fonts need an explicit
// heavy weight to match the printed-card look).
export const FONT_WEIGHTS: Record<string, number> = {
'Baloo 2': 800,
Oswald: 700,
};
let fontsPromise: Promise<{ css: string; fonts: QSLFontInfo[] }> | null = null;
// loadFonts fetches the embedded fonts once, registers them with the
// document and returns the @font-face CSS for SVG embedding.
export function loadFonts(): Promise<{ css: string; fonts: QSLFontInfo[] }> {
if (!fontsPromise) {
fontsPromise = (async () => {
const fonts = (await QSLFonts()) as QSLFontInfo[];
const rules = fonts.map((f) => {
const weight = f.variable ? '100 900' : 'normal';
return `@font-face{font-family:'${f.family}';src:url(data:font/ttf;base64,${f.data_b64}) format('truetype');font-weight:${weight};}`;
});
const css = rules.join('\n');
const el = document.createElement('style');
el.dataset.qslFonts = '1';
el.textContent = css;
document.head.appendChild(el);
await document.fonts.ready;
return { css, fonts };
})();
}
return fontsPromise;
}
async function imageSize(url: string): Promise<{ w: number; h: number }> {
const img = new Image();
img.src = url;
await img.decode();
return { w: img.naturalWidth, h: img.naturalHeight };
}
// Photos are multi-MB base64 payloads; cache them so the three proposals
// (which share the same photo set) and editor re-renders fetch each once.
const photoCache = new Map<string, Promise<PhotoAsset>>();
function loadPhoto(templateId: number, ref: string): Promise<PhotoAsset> {
const key = `${templateId}:${ref}`;
let p = photoCache.get(key);
if (!p) {
p = (async () => {
const url = await QSLPhotoDataURL(templateId, ref);
const size = await imageSize(url);
return { url, ...size };
})();
photoCache.set(key, p);
}
return p;
}
// loadCardAssets resolves everything a template needs to render: each
// referenced photo as a data URL with its natural size, the flag and the
// font CSS. templateId 0 reads draft photos at their original paths.
export async function loadCardAssets(template: CardTemplate, templateId: number): Promise<CardAssets> {
const refs = new Set<string>([template.hero.photo]);
for (const e of template.elements) {
if (e.type === 'insert' && e.photo) refs.add(e.photo);
}
const photos: Record<string, PhotoAsset> = {};
await Promise.all(
[...refs].map(async (ref) => {
photos[ref] = await loadPhoto(templateId, ref);
}),
);
let flagUrl = '';
const country = template.elements.find((e) => e.type === 'country');
if (country?.flag && country.flag !== 'auto') {
flagUrl = await QSLFlagDataURL(country.flag);
}
const { css } = await loadFonts();
return { photos, flagUrl, fontCss: css };
}
+153
View File
@@ -0,0 +1,153 @@
// TypeScript mirror of the QSL template JSON schema (v1) defined in
// internal/qslcard/template.go. Template documents cross the Wails boundary
// as JSON strings; these types are the frontend's contract with that schema.
export interface CardGeom {
w: number;
h: number;
dpi: number;
bleed_px: number;
}
export interface CropRect {
x: number;
y: number;
w: number;
h: number;
}
export interface Scrim {
enabled: boolean;
color: string;
opacity: number;
}
export interface Hero {
photo: string;
crop: CropRect;
scrim?: Scrim;
}
export interface Shine {
coverage: number;
opacity: number;
}
export interface HaloFx {
color: string;
blur: number;
opacity: number;
}
export interface ShadowFx {
dx: number;
dy: number;
blur: number;
color: string;
opacity: number;
}
export interface BevelFx {
dx: number;
dy: number;
dark: string;
light: string;
}
export interface StyleParams {
gradient?: string[];
shine?: Shine;
outline_color?: string;
outline_width?: number;
halo?: HaloFx;
shadow?: ShadowFx;
bevel_offset?: BevelFx;
color?: string;
}
export type ElementType = 'callsign' | 'operator' | 'info_line' | 'country' | 'insert';
export interface CardElement {
type: ElementType;
x: number;
y: number;
rotate?: number;
// text elements
text?: string;
font?: string;
size?: number;
style_preset?: string;
style_params?: StyleParams;
// country
flag?: string;
label?: string;
// insert
photo?: string;
w?: number;
border_px?: number;
shadow?: boolean;
}
export interface QSOBox {
enabled: boolean;
x: number;
y: number;
w: number;
h: number;
bg: string;
bg_opacity: number;
radius: number;
title: string;
fields: string[];
footer: string;
}
export interface CardTemplate {
schema: number;
name: string;
card: CardGeom;
hero: Hero;
elements: CardElement[];
qso_box?: QSOBox;
}
// RenderModel mirrors qslcard.RenderModel: a template with placeholders
// resolved plus the values for the QSO box fields.
export interface RenderModel {
template: CardTemplate;
qso_fields: Record<string, string>;
}
// Binding result types (see app_qsl_designer.go).
export interface QSLTemplateInfo {
id: number;
name: string;
profile_id?: number;
is_default: boolean;
updated_at: string;
}
export interface QSLFontInfo {
family: string;
kind: 'display' | 'script' | 'system';
variable: boolean;
data_b64: string;
}
export interface QSLPresetInfo {
name: string;
label: string;
params: string[];
defaults: StyleParams;
}
// Human labels for the QSO box fields the renderer knows.
export const QSO_FIELD_LABELS: Record<string, string> = {
qso_date: 'Date',
time_on: 'UTC',
band: 'Band',
mode: 'Mode',
rst_sent: 'RST',
freq: 'Freq',
submode: 'Submode',
};
+52
View File
@@ -0,0 +1,52 @@
// Rasterizes the CardPreview SVG to bitmap bytes. The SVG is serialized and
// loaded through an <img>, so every resource it needs (fonts, photos, flag)
// must be inline data URLs — CardPreview guarantees that. Fonts are awaited
// before drawing so glyphs never rasterize with a fallback face.
// rasterizeCard returns base64 image bytes (no data: prefix).
// type 'image/jpeg' targets e-mail (maxBytes enforced by stepping quality
// down); 'image/png' is used for template thumbnails.
export async function rasterizeCard(
svgEl: SVGSVGElement,
outW: number,
outH: number,
type: 'image/jpeg' | 'image/png',
maxBytes = 800 * 1024,
): Promise<string> {
await document.fonts.ready;
const clone = svgEl.cloneNode(true) as SVGSVGElement;
clone.setAttribute('width', String(outW));
clone.setAttribute('height', String(outH));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
try {
const img = new Image();
img.src = url;
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas 2d context unavailable');
if (type === 'image/jpeg') {
ctx.fillStyle = '#ffffff'; // JPEG has no alpha — avoid black background
ctx.fillRect(0, 0, outW, outH);
}
ctx.drawImage(img, 0, 0, outW, outH);
if (type === 'image/png') {
return canvas.toDataURL('image/png').split(',')[1];
}
// Step the JPEG quality down until the e-mail size target is met.
for (const q of [0.9, 0.8, 0.7, 0.6]) {
const b64 = canvas.toDataURL('image/jpeg', q).split(',')[1];
if (b64.length * 0.75 <= maxBytes) return b64;
}
return canvas.toDataURL('image/jpeg', 0.5).split(',')[1];
} finally {
URL.revokeObjectURL(url);
}
}