qsl designer
This commit is contained in:
+117
-25
@@ -14,6 +14,7 @@ import {
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||
@@ -38,6 +39,8 @@ import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm,
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
@@ -430,6 +433,18 @@ export default function App() {
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [error, setError] = useState('');
|
||||
const [migratedBanner, setMigratedBanner] = useState(false);
|
||||
// Secret vault (encrypted passwords): prompt to unlock at launch when a
|
||||
// passphrase is configured but not yet entered this session.
|
||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||
const [unlockPass, setUnlockPass] = useState('');
|
||||
const [unlockErr, setUnlockErr] = useState('');
|
||||
const [unlockBusy, setUnlockBusy] = useState(false);
|
||||
const doUnlock = async () => {
|
||||
setUnlockBusy(true); setUnlockErr('');
|
||||
try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); }
|
||||
catch (e: any) { setUnlockErr(String(e?.message ?? e)); }
|
||||
finally { setUnlockBusy(false); }
|
||||
};
|
||||
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
||||
// like "spot sent" where a blocking error banner would be overkill.
|
||||
const [toast, setToast] = useState('');
|
||||
@@ -460,9 +475,14 @@ export default function App() {
|
||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [recording, recTick]);
|
||||
// restartRecordingForNewTarget (re)starts the take for a new programmatic
|
||||
// target (clicked spot / external app via UDP) and resets the elapsed timer.
|
||||
const restartRecordingForNewTarget = () => {
|
||||
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||
// Lets us restart from zero when the operator edits the call to a different
|
||||
// station mid-recording, instead of continuing the old take.
|
||||
const recordingCallRef = useRef('');
|
||||
// restartRecordingForNewTarget (re)starts the take for a new target (clicked
|
||||
// spot / external app / edited callsign) and resets the elapsed timer.
|
||||
const restartRecordingForNewTarget = (forCall?: string) => {
|
||||
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
|
||||
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||
};
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -474,6 +494,8 @@ export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('recent');
|
||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||
const [eqslQsoId, setEqslQsoId] = useState<number | null>(null); // QSO being sent as eQSL
|
||||
function closeQslTab() {
|
||||
setQslTabOpen(false);
|
||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||
@@ -786,7 +808,8 @@ export default function App() {
|
||||
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
||||
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
||||
const offDone = EventsOn('qslmgr:done', ping);
|
||||
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
|
||||
const offEqsl = EventsOn('qsl:sent', ping);
|
||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||
}, [refresh]);
|
||||
|
||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||
@@ -917,9 +940,9 @@ export default function App() {
|
||||
if (s.band) setBand(s.band);
|
||||
}
|
||||
if (m) applyModeFromSpot(m);
|
||||
onCallsignInput(s.dx_call);
|
||||
onCallsignInput(s.dx_call, { force: true });
|
||||
applySpotPOTA((s as any).pota_ref);
|
||||
if (s.dx_call?.trim()) restartRecordingForNewTarget();
|
||||
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
@@ -930,6 +953,11 @@ export default function App() {
|
||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
||||
if ((st as any).migrated_from_app_data) setMigratedBanner(true);
|
||||
} catch {}
|
||||
// Prompt to unlock encrypted passwords if a passphrase is configured.
|
||||
try {
|
||||
const ss: any = await GetSecretStatus();
|
||||
if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true);
|
||||
} catch {}
|
||||
loadStation();
|
||||
loadLists();
|
||||
loadCATCfg();
|
||||
@@ -1063,15 +1091,15 @@ export default function App() {
|
||||
lastUdpCallRef.current = upper; // remember this broadcast either way
|
||||
if (current === upper) return false; // already shown → no-op
|
||||
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
|
||||
onCallsignInput(call);
|
||||
onCallsignInput(call, { force: true }); // programmatic → always look up
|
||||
return true;
|
||||
};
|
||||
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
||||
// External app moved to a new station → fresh recording for the new target.
|
||||
if (applyUdpCall(p?.call)) restartRecordingForNewTarget();
|
||||
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
||||
});
|
||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||
if (applyUdpCall(raw)) restartRecordingForNewTarget();
|
||||
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? ''));
|
||||
});
|
||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||
@@ -1266,7 +1294,7 @@ export default function App() {
|
||||
function resetEntry() {
|
||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||
// log, or if the recorder is off).
|
||||
QSOAudioCancel(); setRecording(false);
|
||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||
setCallsign(''); setComment(''); setNote('');
|
||||
if (!locks.start) setQsoStartedAt(null);
|
||||
if (!locks.end) setQsoEndedAt(null);
|
||||
@@ -1470,17 +1498,23 @@ export default function App() {
|
||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||
}));
|
||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||
// Begin the recording once the call resolves (a real, ≥3-char callsign,
|
||||
// not 1–2 stray letters). Covers the fast CW workflow (type → Enter to log
|
||||
// via the WinKeyer, no blur). No-op if the recorder is off or already
|
||||
// running; the pre-roll covers the lead-in.
|
||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
// Recording: tie it to the resolved callsign. Start once a real (≥3-char)
|
||||
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
|
||||
// we're already recording a DIFFERENT call (the operator edited the
|
||||
// callsign), restart from zero instead of continuing the old take.
|
||||
const recCall = call.toUpperCase();
|
||||
if (recordingCallRef.current && recordingCallRef.current !== recCall) {
|
||||
restartRecordingForNewTarget(recCall);
|
||||
} else {
|
||||
recordingCallRef.current = recCall;
|
||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
setLookupResult(null);
|
||||
setLookupError(String(e?.message ?? e));
|
||||
} finally { setLookupBusy(false); }
|
||||
}
|
||||
function scheduleLookup(value: string) {
|
||||
function scheduleLookup(value: string, force?: boolean) {
|
||||
setLookupError('');
|
||||
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
|
||||
if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current);
|
||||
@@ -1490,8 +1524,14 @@ export default function App() {
|
||||
if (lastLookedUpRef.current !== '') resetAutoFill();
|
||||
return;
|
||||
}
|
||||
lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
|
||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||
// Option: defer the (network) callsign lookup until the operator leaves the
|
||||
// Call field instead of firing it as they type. `force` (programmatic set —
|
||||
// clicked spot / external app) always looks up, since there's no blur to
|
||||
// wait for. Worked-before stays live (local, feeds the band matrix).
|
||||
if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') {
|
||||
lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400);
|
||||
}
|
||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||
}
|
||||
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
|
||||
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
|
||||
@@ -1501,7 +1541,7 @@ export default function App() {
|
||||
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
|
||||
}
|
||||
function onCallsignInput(v: string) {
|
||||
function onCallsignInput(v: string, opts?: { force?: boolean }) {
|
||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||
// on every status packet. If it matches what's already in the entry,
|
||||
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
|
||||
@@ -1512,7 +1552,7 @@ export default function App() {
|
||||
// keeps the pre-roll from before this); clearing it discards the take.
|
||||
// Recording START happens on blur (leaving the callsign field), NOT here —
|
||||
// you may type a call and work it minutes later. Clearing it cancels.
|
||||
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); }
|
||||
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
|
||||
const wasEmpty = callsign.trim() === '';
|
||||
const isEmpty = v.trim() === '';
|
||||
if (wasEmpty && !isEmpty && !locks.start) {
|
||||
@@ -1526,7 +1566,10 @@ export default function App() {
|
||||
setQsoStartedAt(null);
|
||||
}
|
||||
setCallsign(v);
|
||||
scheduleLookup(v);
|
||||
// opts.force = the call was set programmatically (clicked spot / external
|
||||
// app): there's no "leaving the field", so look it up now regardless of the
|
||||
// lookup-on-blur option.
|
||||
scheduleLookup(v, opts?.force);
|
||||
}
|
||||
function markEdited(field: string) { userEditedRef.current.add(field); }
|
||||
|
||||
@@ -1608,6 +1651,7 @@ export default function App() {
|
||||
]},
|
||||
{ name: 'tools', label: 'Tools', items: [
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'item', label: 'QSL Card Designer…', action: 'tools.qsldesigner' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||
@@ -1632,6 +1676,7 @@ export default function App() {
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
@@ -1749,9 +1794,14 @@ export default function App() {
|
||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||
value={callsign}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
// Start the QSO recording when leaving the callsign field (the pre-roll
|
||||
// covers the seconds before). No-op when the recorder is off.
|
||||
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
|
||||
onBlur={() => {
|
||||
const c = callsign.trim();
|
||||
if (!c) return;
|
||||
// Lookup-on-blur mode: run the deferred lookup now (it also starts the
|
||||
// recording). Otherwise just start the recording (lookup already ran).
|
||||
if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase());
|
||||
else QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2264,6 +2314,38 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||
skipping leaves lookups/uploads without their passwords until unlocked. */}
|
||||
{unlockOpen && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-[360px] rounded-lg border border-border bg-card shadow-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Lock className="size-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">Unlock saved passwords</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session.
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={unlockPass}
|
||||
placeholder="Passphrase"
|
||||
onChange={(e) => { setUnlockPass(e.target.value); setUnlockErr(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }}
|
||||
className="mb-2"
|
||||
/>
|
||||
{unlockErr && <div className="text-xs text-destructive mb-2">{unlockErr}</div>}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setUnlockOpen(false); setUnlockPass(''); setUnlockErr(''); }}>Later</Button>
|
||||
<Button size="sm" disabled={!unlockPass || unlockBusy} onClick={doUnlock}>
|
||||
{unlockBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />} Unlock
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* "You have been spotted" banner — shows when our own callsign appears
|
||||
in a cluster spot (Log4OM-style). Floated as a bottom-center overlay
|
||||
@@ -2587,6 +2669,7 @@ export default function App() {
|
||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo}
|
||||
onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onExportSelected={exportSelectedADIF}
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
@@ -2933,7 +3016,8 @@ export default function App() {
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
@@ -3089,6 +3173,14 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||
<SendEQSLModal
|
||||
open={eqslQsoId !== null}
|
||||
qsoId={eqslQsoId}
|
||||
onClose={() => setEqslQsoId(null)}
|
||||
onOpenDesigner={() => setQslDesignerOpen(true)}
|
||||
/>
|
||||
|
||||
{deletingQSO && (
|
||||
<ConfirmDialog
|
||||
title="Delete QSO?"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
|
||||
Reference in New Issue
Block a user