qsl designer
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 1–6 photos — OpsLog analyzes them and proposes three card designs
|
||||
with your callsign, name, zones and country placed automatically.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={choosePhotos}>
|
||||
<Images className="mr-1 size-4" /> Choose photos…
|
||||
</Button>
|
||||
<Button size="sm" disabled={!photoPaths.length || busy} onClick={generate}>
|
||||
{busy ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
|
||||
Generate designs
|
||||
</Button>
|
||||
{photoPaths.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{photoPaths.length} photo{photoPaths.length > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{saved.map((t) => (
|
||||
<div key={t.id} className="rounded-md border border-border p-2">
|
||||
{previews[t.id]
|
||||
? <img src={previews[t.id]} alt={t.name} className="w-full rounded" />
|
||||
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-0.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Edit"
|
||||
onClick={() => editSaved(t)}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
{!t.is_default && (
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Set as default"
|
||||
onClick={() => makeDefault(t.id)}>
|
||||
<Star className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm"
|
||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
|
||||
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">eQSL e-mail message</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'} fill in per QSO.
|
||||
</p>
|
||||
<Input className="h-8" value={mailSubject} placeholder="Subject"
|
||||
onChange={(e) => { setMailSubject(e.target.value); setMailSaved(false); }} />
|
||||
<Textarea rows={3} value={mailBody} placeholder="Body"
|
||||
onChange={(e) => { setMailBody(e.target.value); setMailSaved(false); }} />
|
||||
<Button variant="outline" size="sm" disabled={mailSaved}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await QSLSaveEmailTemplates(new main.QSLEmailTemplates({ subject: mailSubject, body: mailBody }));
|
||||
setMailSaved(true);
|
||||
} catch (e) { setError(String(e)); }
|
||||
}}>
|
||||
{mailSaved ? 'Saved' : 'Save message'}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'proposals' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Pick a design to refine it:</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{proposals.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
|
||||
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||
>
|
||||
<CardPreview model={p.model} assets={p.assets} width={352} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'editor' && editing && (
|
||||
<div className="flex gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="rounded-md bg-slate-800/60 p-3">
|
||||
<CardPreview
|
||||
model={editing.model}
|
||||
assets={editing.assets}
|
||||
width={760}
|
||||
selected={sel}
|
||||
onSelect={setSel}
|
||||
onMove={onMove}
|
||||
svgRef={(el) => { svgEl.current = el; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input className="h-8 w-56" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Vietnam sunset" />
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox checked={asDefault} onCheckedChange={(v) => setAsDefault(v === true)} />
|
||||
Default for this profile
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" disabled={busy || !name.trim()} onClick={save}>
|
||||
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Save template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EditorPanel
|
||||
template={editing.template}
|
||||
sel={sel}
|
||||
presets={presets}
|
||||
fontFamilies={fontFamilies}
|
||||
onPatchElement={patchElement}
|
||||
onPatchBox={patchBox}
|
||||
onScrim={onScrim}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user