up
This commit is contained in:
@@ -3201,7 +3201,7 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<AutoEQSL
|
||||
onSent={(call) => showToast(`eQSL sent to ${call}`)}
|
||||
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
|
||||
onError={(msg) => showToast(msg)}
|
||||
/>
|
||||
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||
|
||||
@@ -101,6 +101,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return m;
|
||||
}, [wb]);
|
||||
|
||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||
const curClass = CLASSES.find((c) => classMatchesMode(c, currentMode));
|
||||
const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined;
|
||||
const bandWorked = CLASSES.some((c) => statusMap.get(`${currentBand}|${c}`));
|
||||
const modeWorked = !!curClass && cols.some((b) => statusMap.get(`${b.tag}|${curClass}`));
|
||||
const newBand = hasDxcc && !newOne && !bandWorked;
|
||||
const newMode = hasDxcc && !newOne && !!curClass && !modeWorked;
|
||||
const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus;
|
||||
// New slot for THIS call: worked the op before, but not on this band+mode.
|
||||
const newSlot = !newOne && callCount > 0 && !!curClass && slotStatus !== 'call_c' && slotStatus !== 'call_w';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -136,6 +147,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{(newBand || newMode || newBandMode || newSlot) && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||
{!newBand && !newMode && newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : busy ? (
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
||||
|
||||
@@ -90,7 +90,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||
>
|
||||
<Mail className="size-4 text-amber-600" />
|
||||
<span>Send eQSL by e-mail</span>
|
||||
<span>Send OpsLog QSL by e-mail</span>
|
||||
</button>
|
||||
)}
|
||||
{onSendRecording && (
|
||||
|
||||
@@ -101,8 +101,8 @@ export const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
@@ -150,6 +150,8 @@ export const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
|
||||
@@ -189,7 +189,6 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
||||
{ kind: 'item', label: 'Database', id: 'database' },
|
||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -455,11 +454,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
// E-mail / SMTP (send QSO recordings).
|
||||
type EmailCfg = {
|
||||
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
||||
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
||||
from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
||||
};
|
||||
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
||||
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
||||
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||
});
|
||||
const [emailMsg, setEmailMsg] = useState('');
|
||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||
@@ -3076,6 +3075,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<Label className="text-sm">From address</Label>
|
||||
<Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||
<Label className="text-sm">Reply-To address</Label>
|
||||
<div>
|
||||
<Input className="h-8" placeholder="(optional — where replies go)" value={emailCfg.reply_to} onChange={(e) => setEmailField({ reply_to: e.target.value })} />
|
||||
<div className="text-[10px] text-muted-foreground mt-1">Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" className="h-8"
|
||||
@@ -3086,7 +3090,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
||||
<Label className="text-sm font-semibold">eQSL card e-mail</Label>
|
||||
<Label className="text-sm font-semibold">OpsLog QSL card e-mail</Label>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
||||
</div>
|
||||
@@ -3096,7 +3100,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
onChange={(e) => setEqslField({ body: e.target.value })} />
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
|
||||
Auto-send eQSL when a QSO is logged
|
||||
Auto-send OpsLog QSL when a QSO is logged
|
||||
</label>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
|
||||
|
||||
@@ -28,6 +28,14 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
||||
const busy = useRef(false);
|
||||
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
||||
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
|
||||
|
||||
// Keep the callbacks in refs so they never change the effects' identity — a
|
||||
// toast/grid re-render from onSent must NOT re-run the send effect (that
|
||||
// re-sent the same eQSL many times in a row).
|
||||
const onSentRef = useRef(onSent);
|
||||
const onErrorRef = useRef(onError);
|
||||
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
|
||||
|
||||
// Pull the next job, fetch its render model + assets, then mount it (the
|
||||
// effect below rasterizes once the DOM has it).
|
||||
@@ -41,14 +49,16 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
||||
const assets = await loadCardAssets(model.template, job.templateId);
|
||||
setCurrent({ job, model, assets });
|
||||
} catch (e) {
|
||||
onError?.(`Auto eQSL: ${e}`);
|
||||
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||
busy.current = false;
|
||||
void pump();
|
||||
}
|
||||
}, [onError]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
|
||||
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
|
||||
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
|
||||
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
|
||||
void pump();
|
||||
});
|
||||
@@ -56,20 +66,23 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
||||
}, [pump]);
|
||||
|
||||
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
|
||||
// Sends exactly once per job (guarded by sentForRef), independent of renders.
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
if (sentForRef.current === current.job.qsoId) return; // already sent this one
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
||||
if (cancelled || !svgEl.current) return;
|
||||
sentForRef.current = current.job.qsoId;
|
||||
const card = current.model.template.card;
|
||||
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
||||
onSent?.(current.job.callsign);
|
||||
onSentRef.current?.(current.job.callsign);
|
||||
} catch (e) {
|
||||
onError?.(`Auto eQSL: ${e}`);
|
||||
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setCurrent(null);
|
||||
@@ -79,7 +92,7 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [current, pump, onSent, onError]);
|
||||
}, [current, pump]);
|
||||
|
||||
if (!current) return null;
|
||||
// Off-screen at full card resolution so the rasterized output matches the
|
||||
|
||||
@@ -72,10 +72,11 @@ function buildFxParams(e: CardElement): TextFxParams | null {
|
||||
size: e.size,
|
||||
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
||||
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
|
||||
// Dark inter-letter edge — fixed near-black (the navy outline adaptStyle
|
||||
// sets is for the old SVG stack, not this look).
|
||||
cDark: kind === 'western' ? '#1c130a' : '#262630',
|
||||
cOuter: silver ? '#e8edf2' : '#ced3db',
|
||||
// Dark inter-letter edge + rim — from the chosen colour palette, else the
|
||||
// default near-black edge (the navy outline adaptStyle sets is for the old
|
||||
// SVG stack, not this look).
|
||||
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
|
||||
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
|
||||
// Per-call overrides from the editor (undefined → renderer default).
|
||||
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
||||
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
|
||||
|
||||
@@ -96,7 +96,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
async function choosePhotos() {
|
||||
try {
|
||||
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
||||
if (paths.length) setPhotoPaths(paths.slice(0, 3));
|
||||
if (paths.length) setPhotoPaths(paths.slice(0, 5));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">New design</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick 1–3 photos — OpsLog analyzes them and proposes three card designs
|
||||
Pick 1–5 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">
|
||||
|
||||
@@ -75,7 +75,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||
<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
|
||||
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||
<DialogFooter>
|
||||
{sent ? (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<CheckCircle2 className="size-4" /> eQSL sent.
|
||||
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -16,6 +16,25 @@ interface Props {
|
||||
onChange: (preset: string, params: StyleParams) => void;
|
||||
}
|
||||
|
||||
// Quick colour palettes per FX family (mirrors the reference generators):
|
||||
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
|
||||
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
|
||||
const GLOSSY_PALETTES: Palette[] = [
|
||||
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
|
||||
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
|
||||
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
|
||||
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
|
||||
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
|
||||
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
|
||||
];
|
||||
const WESTERN_PALETTES: Palette[] = [
|
||||
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
|
||||
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
|
||||
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
|
||||
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
|
||||
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
|
||||
];
|
||||
|
||||
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -76,6 +95,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isFx && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Palette</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
|
||||
<button
|
||||
key={p.name} type="button" title={p.name}
|
||||
className="h-6 w-6 rounded border border-border"
|
||||
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
|
||||
onClick={() => onChange(preset, {
|
||||
...params,
|
||||
gradient: [p.top, p.mid, p.bot],
|
||||
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{has('color') && (
|
||||
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
||||
)}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface FxParams {
|
||||
grunge?: number;
|
||||
bevel?: number;
|
||||
seed?: number;
|
||||
dark?: string;
|
||||
outer?: string;
|
||||
}
|
||||
|
||||
export interface StyleParams {
|
||||
|
||||
Reference in New Issue
Block a user