chore(labels): park the label designer and printing on feature/labels
The feature needs more rounds than the next release can wait for, so main goes back to before it: the packages, bindings, migration, UI and i18n all move to the feature/labels branch, which holds every commit. The 0.26.23 block keeps only the Station column. The 0031 migration may already have run on a machine that launched a dev build; the two label tables it created are inert and the recorded migration row is harmless — the runner only applies filenames it has, so re-adding the migration when the branch merges will skip cleanly there and apply everywhere else.
This commit is contained in:
@@ -1,555 +0,0 @@
|
||||
// Label Designer — QSO labels for QSL cards and address labels for envelopes.
|
||||
//
|
||||
// Same architecture as the QSL card designer, stripped to what a monochrome
|
||||
// sticker needs: saved templates on the left, a mm-true canvas on the right,
|
||||
// click to select, drag to move. The canvas renderer (labelRender) is shared
|
||||
// with the future print path, so what the preview shows is what the PDF gets.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { X, Plus, Trash2, Star, Tag, MapPin } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
LabelListStocks, LabelSaveStock, LabelDeleteStock,
|
||||
LabelListTemplates, LabelGetTemplate, LabelSaveTemplate, LabelDeleteTemplate,
|
||||
LabelSetDefaultTemplate, LabelSampleQSOs, GetActiveProfile,
|
||||
} from '../../../wailsjs/go/main/App';
|
||||
import type { LabelElement, LabelSample, LabelStock, LabelTemplate } from './labelTypes';
|
||||
import { LABEL_VARS, TABLE_FIELDS, starterTemplate } from './labelTypes';
|
||||
import { drawMargins, render, type ElementBox } from './labelRender';
|
||||
|
||||
interface Props { open: boolean; onClose: () => void }
|
||||
|
||||
interface TplInfo {
|
||||
id: number; name: string; kind: string; stock_id: number;
|
||||
is_default: boolean; updated_at: string;
|
||||
}
|
||||
|
||||
// The preview fills whatever room the centre pane offers — a label cut off at
|
||||
// the edge cannot be judged, and 90 mm across a fixed 720 px wasted half the
|
||||
// window. Measured live so a resize re-fits.
|
||||
const PREVIEW_MIN_SCALE = 2;
|
||||
|
||||
export function LabelDesignerModal({ open, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [stocks, setStocks] = useState<LabelStock[]>([]);
|
||||
const [saved, setSaved] = useState<TplInfo[]>([]);
|
||||
const [samples, setSamples] = useState<LabelSample[]>([]);
|
||||
const [myVars, setMyVars] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// The design being edited (null = nothing open yet).
|
||||
const [tplId, setTplId] = useState(0);
|
||||
const [tpl, setTpl] = useState<LabelTemplate | null>(null);
|
||||
const [tplName, setTplName] = useState('');
|
||||
const [forProfile, setForProfile] = useState(true);
|
||||
const [sel, setSel] = useState<number | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [deleteArm, setDeleteArm] = useState(0);
|
||||
|
||||
// Stock editor (collapsed by default — geometry is set once per roll).
|
||||
const [stockOpen, setStockOpen] = useState(false);
|
||||
const [stockDraft, setStockDraft] = useState<LabelStock | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
const [paneSize, setPaneSize] = useState({ w: 720, h: 420 });
|
||||
useEffect(() => {
|
||||
const el = paneRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setPaneSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [open, tpl != null]);
|
||||
const boxesRef = useRef<ElementBox[]>([]);
|
||||
const dragRef = useRef<{ idx: number; dxMm: number; dyMm: number } | null>(null);
|
||||
|
||||
const stock = useMemo(
|
||||
() => stocks.find((s) => s.id === tpl?.stock_id) ?? stocks[0],
|
||||
[stocks, tpl?.stock_id]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStocks((await LabelListStocks()) as any as LabelStock[]);
|
||||
setSaved(((await LabelListTemplates()) ?? []) as any as TplInfo[]);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(''); setTpl(null); setTplId(0); setSel(null); setDirty(false);
|
||||
void refresh();
|
||||
LabelSampleQSOs(5).then((r: any) => setSamples(r ?? [])).catch(() => {});
|
||||
// The preview resolves <MY*> from the active profile so the address label
|
||||
// reads like the finished sticker, not like a form.
|
||||
GetActiveProfile().then((p: any) => setMyVars({
|
||||
MYCALL: p?.callsign ?? 'MYCALL', MYNAME: p?.op_name ?? p?.operator ?? '',
|
||||
MYSTREET: p?.my_street ?? '', MYZIP: p?.my_postal_code ?? '', MYCITY: p?.my_city ?? '',
|
||||
MYCOUNTRY: p?.my_country ?? '',
|
||||
})).catch(() => setMyVars({ MYCALL: 'MYCALL' }));
|
||||
}, [open, refresh]);
|
||||
|
||||
// Preview variable values: the first sample QSO plays the DX station.
|
||||
const vars = useMemo(() => {
|
||||
const q = samples[0];
|
||||
return {
|
||||
CALL: q?.callsign ?? 'DL1ABC', NAME: q?.name ?? 'Hans', QTH: q?.qth ?? 'Berlin',
|
||||
COUNTRY: q?.country ?? 'Germany', VIA: 'DJ5XX',
|
||||
STREET: 'Funkerstrasse 12', ZIP: '10115', CITY: q?.qth || 'Berlin', STATE: '',
|
||||
...myVars,
|
||||
} as Record<string, string>;
|
||||
}, [samples, myVars]);
|
||||
|
||||
// ── canvas ────────────────────────────────────────────────────────────
|
||||
const scale = useMemo(() => {
|
||||
if (!stock) return 4;
|
||||
// Leave room for the pane's padding and the two control strips above and
|
||||
// below the canvas; never shrink below a scale where 8 pt is readable.
|
||||
const availW = Math.max(200, paneSize.w - 56);
|
||||
const availH = Math.max(120, paneSize.h - 130);
|
||||
return Math.max(PREVIEW_MIN_SCALE, Math.min(availW / stock.w_mm, availH / stock.h_mm));
|
||||
}, [stock, paneSize]);
|
||||
|
||||
const paint = useCallback(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv || !tpl || !stock) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
cv.width = Math.round(stock.w_mm * scale * dpr);
|
||||
cv.height = Math.round(stock.h_mm * scale * dpr);
|
||||
cv.style.width = `${stock.w_mm * scale}px`;
|
||||
cv.style.height = `${stock.h_mm * scale}px`;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
boxesRef.current = render(ctx, tpl, stock, { vars, qsos: samples }, scale);
|
||||
drawMargins(ctx, stock, scale);
|
||||
// Selection outline, drawn after everything: the editor's own chrome.
|
||||
if (sel != null && boxesRef.current[sel]) {
|
||||
const b = boxesRef.current[sel];
|
||||
ctx.save();
|
||||
ctx.scale(scale, scale);
|
||||
ctx.strokeStyle = 'rgba(255,140,0,0.9)';
|
||||
ctx.lineWidth = 0.3;
|
||||
ctx.setLineDash([1, 0.8]);
|
||||
ctx.strokeRect(b.x - 0.6, b.y - 0.6, b.w + 1.2, b.h + 1.2);
|
||||
ctx.restore();
|
||||
}
|
||||
}, [tpl, stock, vars, samples, scale, sel]);
|
||||
|
||||
useEffect(() => { paint(); }, [paint]);
|
||||
|
||||
const mmFromEvent = (ev: React.MouseEvent): { x: number; y: number } => {
|
||||
const r = canvasRef.current!.getBoundingClientRect();
|
||||
return { x: (ev.clientX - r.left) / scale, y: (ev.clientY - r.top) / scale };
|
||||
};
|
||||
|
||||
const onCanvasDown = (ev: React.MouseEvent) => {
|
||||
if (!tpl) return;
|
||||
const p = mmFromEvent(ev);
|
||||
// Topmost element under the pointer wins — iterate backwards.
|
||||
for (let i = boxesRef.current.length - 1; i >= 0; i--) {
|
||||
const b = boxesRef.current[i];
|
||||
if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) {
|
||||
setSel(i);
|
||||
dragRef.current = { idx: i, dxMm: p.x - tpl.elements[i].x_mm, dyMm: p.y - tpl.elements[i].y_mm };
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSel(null);
|
||||
};
|
||||
const onCanvasMove = (ev: React.MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || !tpl) return;
|
||||
const p = mmFromEvent(ev);
|
||||
// Snapped to 0.5 mm: free pixels look precise on screen and print ragged.
|
||||
const x = Math.round((p.x - d.dxMm) * 2) / 2;
|
||||
const y = Math.round((p.y - d.dyMm) * 2) / 2;
|
||||
patchEl(d.idx, { x_mm: x, y_mm: y });
|
||||
};
|
||||
const onCanvasUp = () => { dragRef.current = null; };
|
||||
|
||||
// ── template edits ────────────────────────────────────────────────────
|
||||
function patchEl(idx: number, patch: Partial<LabelElement>) {
|
||||
setTpl((cur) => {
|
||||
if (!cur) return cur;
|
||||
const elements = cur.elements.map((e, i) => (i === idx ? { ...e, ...patch } : e));
|
||||
return { ...cur, elements };
|
||||
});
|
||||
setDirty(true);
|
||||
}
|
||||
function addElement(e: LabelElement) {
|
||||
setTpl((cur) => (cur ? { ...cur, elements: [...cur.elements, e] } : cur));
|
||||
setSel(tpl ? tpl.elements.length : 0);
|
||||
setDirty(true);
|
||||
}
|
||||
function removeSelected() {
|
||||
if (sel == null) return;
|
||||
setTpl((cur) => (cur ? { ...cur, elements: cur.elements.filter((_, i) => i !== sel) } : cur));
|
||||
setSel(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function newTemplate(kind: 'qso' | 'address') {
|
||||
const s = stocks[0];
|
||||
if (!s) { setError(t('lbl.noStock')); return; }
|
||||
setTpl(starterTemplate(kind, s));
|
||||
setTplId(0);
|
||||
setTplName(kind === 'qso' ? t('lbl.newQsoName') : t('lbl.newAddrName'));
|
||||
setSel(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function openTemplate(info: TplInfo) {
|
||||
try {
|
||||
const doc = await LabelGetTemplate(info.id);
|
||||
setTpl(JSON.parse(doc) as LabelTemplate);
|
||||
setTplId(info.id);
|
||||
setTplName(info.name);
|
||||
setSel(null);
|
||||
setDirty(false);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!tpl) return;
|
||||
try {
|
||||
const id = await LabelSaveTemplate(tplId, tplName.trim() || 'Label', JSON.stringify(tpl), forProfile);
|
||||
setTplId(id as number);
|
||||
setDirty(false);
|
||||
await refresh();
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function removeTemplate(id: number) {
|
||||
if (deleteArm !== id) { setDeleteArm(id); window.setTimeout(() => setDeleteArm(0), 2500); return; }
|
||||
try {
|
||||
await LabelDeleteTemplate(id);
|
||||
if (id === tplId) { setTpl(null); setTplId(0); }
|
||||
await refresh();
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// ── stock editor ──────────────────────────────────────────────────────
|
||||
async function saveStock() {
|
||||
if (!stockDraft) return;
|
||||
try {
|
||||
await LabelSaveStock(stockDraft as any);
|
||||
setStockDraft(null);
|
||||
await refresh();
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
const e = sel != null && tpl ? tpl.elements[sel] : undefined;
|
||||
|
||||
const numField = (label: string, value: number, set: (v: number) => void, step = 0.5, w = 'w-20') => (
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px] text-muted-foreground">{label}</Label>
|
||||
<Input type="number" step={step} value={value} className={cn('h-7 text-xs font-mono', w)}
|
||||
onChange={(ev) => set(parseFloat(ev.target.value) || 0)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-6xl h-[92vh] flex flex-col overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Tag className="size-4 text-primary" />
|
||||
<span className="font-semibold text-sm">{t('lbl.title')}</span>
|
||||
<div className="flex-1" />
|
||||
{tpl && (
|
||||
<>
|
||||
<Input className="h-8 w-56 text-sm" value={tplName} onChange={(ev) => { setTplName(ev.target.value); setDirty(true); }}
|
||||
placeholder={t('lbl.namePh')} />
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox checked={forProfile} onCheckedChange={(c) => setForProfile(!!c)} />
|
||||
{t('lbl.forProfile')}
|
||||
</label>
|
||||
<Button size="sm" className="h-8" onClick={save} disabled={!dirty && tplId !== 0}>
|
||||
{t('lbl.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onClose}><X className="size-4" /></Button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-1.5 text-xs text-danger border-b border-border/60">{error}</div>}
|
||||
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
{/* left: saved templates + stocks */}
|
||||
<div className="w-64 border-r border-border flex flex-col min-h-0 shrink-0">
|
||||
<div className="p-2 flex gap-1.5">
|
||||
<Button size="sm" variant="outline" className="h-8 flex-1" onClick={() => newTemplate('qso')}>
|
||||
<Plus className="size-3.5" /> {t('lbl.newQso')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 flex-1" onClick={() => newTemplate('address')}>
|
||||
<Plus className="size-3.5" /> {t('lbl.newAddr')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-1">
|
||||
{saved.map((s) => (
|
||||
<div key={s.id}
|
||||
className={cn('rounded-md border px-2 py-1.5 cursor-pointer text-xs',
|
||||
s.id === tplId ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50')}
|
||||
onClick={() => void openTemplate(s)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{s.kind === 'qso' ? <Tag className="size-3 shrink-0 text-muted-foreground" /> : <MapPin className="size-3 shrink-0 text-muted-foreground" />}
|
||||
<span className="font-medium truncate flex-1">{s.name}</span>
|
||||
<button type="button" title={t('lbl.setDefault')}
|
||||
onClick={(ev) => { ev.stopPropagation(); void LabelSetDefaultTemplate(s.id).then(refresh); }}>
|
||||
<Star className={cn('size-3.5', s.is_default ? 'text-warning fill-warning' : 'text-muted-foreground/40')} />
|
||||
</button>
|
||||
<button type="button" title={t('lbl.delete')}
|
||||
onClick={(ev) => { ev.stopPropagation(); void removeTemplate(s.id); }}>
|
||||
<Trash2 className={cn('size-3.5', deleteArm === s.id ? 'text-danger' : 'text-muted-foreground/40')} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground pl-4">
|
||||
{s.kind === 'qso' ? t('lbl.kindQso') : t('lbl.kindAddr')} · {s.updated_at}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{saved.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-1 py-3">{t('lbl.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* stocks */}
|
||||
<div className="border-t border-border p-2 space-y-1.5">
|
||||
<button type="button" className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
onClick={() => setStockOpen((v) => !v)}>
|
||||
{t('lbl.stocks')} {stockOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{stockOpen && (
|
||||
<div className="space-y-1.5">
|
||||
<Select value={String(stockDraft?.id ?? '')} onValueChange={(v) => {
|
||||
const s = stocks.find((x) => String(x.id) === v);
|
||||
if (s) setStockDraft({ ...s });
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder={t('lbl.pickStock')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{stocks.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs flex-1"
|
||||
onClick={() => setStockDraft({ name: t('lbl.customStock'), w_mm: 90, h_mm: 29, margin_top_mm: 1.5, margin_right_mm: 3, margin_bottom_mm: 1.5, margin_left_mm: 3, dpi: 300 })}>
|
||||
<Plus className="size-3" /> {t('lbl.newStock')}
|
||||
</Button>
|
||||
{stockDraft?.id ? (
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs text-danger"
|
||||
onClick={() => { void LabelDeleteStock(stockDraft.id!).then(() => { setStockDraft(null); return refresh(); }); }}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{stockDraft && (
|
||||
<div className="space-y-1.5">
|
||||
<Input className="h-7 text-xs" value={stockDraft.name}
|
||||
onChange={(ev) => setStockDraft({ ...stockDraft, name: ev.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{numField(t('lbl.widthMm'), stockDraft.w_mm, (v) => setStockDraft({ ...stockDraft, w_mm: v }), 0.5, 'w-full')}
|
||||
{numField(t('lbl.heightMm'), stockDraft.h_mm, (v) => setStockDraft({ ...stockDraft, h_mm: v }), 0.5, 'w-full')}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{numField('↑', stockDraft.margin_top_mm, (v) => setStockDraft({ ...stockDraft, margin_top_mm: v }), 0.5, 'w-full')}
|
||||
{numField('→', stockDraft.margin_right_mm, (v) => setStockDraft({ ...stockDraft, margin_right_mm: v }), 0.5, 'w-full')}
|
||||
{numField('↓', stockDraft.margin_bottom_mm, (v) => setStockDraft({ ...stockDraft, margin_bottom_mm: v }), 0.5, 'w-full')}
|
||||
{numField('←', stockDraft.margin_left_mm, (v) => setStockDraft({ ...stockDraft, margin_left_mm: v }), 0.5, 'w-full')}
|
||||
</div>
|
||||
<Button size="sm" className="h-7 text-xs w-full" onClick={() => void saveStock()}>{t('lbl.saveStock')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* centre: preview */}
|
||||
<div ref={paneRef} className="flex-1 min-w-0 flex flex-col items-center justify-center bg-muted/30 overflow-auto p-6 gap-3">
|
||||
{tpl && stock ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('lbl.stock')}:</span>
|
||||
<Select value={String(tpl.stock_id || stock.id)} onValueChange={(v) => {
|
||||
setTpl({ ...tpl, stock_id: parseInt(v, 10) }); setDirty(true);
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs w-72"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{stocks.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="font-mono">{stock.w_mm}×{stock.h_mm} mm</span>
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="shadow-lg rounded-sm cursor-move"
|
||||
onMouseDown={onCanvasDown} onMouseMove={onCanvasMove}
|
||||
onMouseUp={onCanvasUp} onMouseLeave={onCanvasUp} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'text', x_mm: stock.margin_left_mm, y_mm: stock.h_mm / 2, text: t('lbl.newText'), size_pt: 9 })}>
|
||||
<Plus className="size-3" /> {t('lbl.addText')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'line', x_mm: stock.margin_left_mm, y_mm: stock.h_mm / 2, w_mm: stock.w_mm - stock.margin_left_mm - stock.margin_right_mm, thickness_mm: 0.3 })}>
|
||||
<Plus className="size-3" /> {t('lbl.addLine')}
|
||||
</Button>
|
||||
{tpl.kind === 'address' && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'addr_block', x_mm: stock.margin_left_mm + 2, y_mm: stock.margin_top_mm + 2, lines: ['<NAME>', '<STREET>', '<ZIP> <CITY>', '<COUNTRY>'], size_pt: 11, line_gap_mm: 1.6 })}>
|
||||
<Plus className="size-3" /> {t('lbl.addAddr')}
|
||||
</Button>
|
||||
)}
|
||||
{tpl.kind === 'qso' && !tpl.elements.some((x) => x.type === 'qso_table') && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'qso_table', x_mm: stock.margin_left_mm, y_mm: stock.margin_top_mm + 7, w_mm: stock.w_mm - stock.margin_left_mm - stock.margin_right_mm, rows_max: 4, row_h_mm: 4, header: true, size_pt: 7.5, columns: TABLE_FIELDS.slice(0, 6).map((c) => ({ ...c })) })}>
|
||||
<Plus className="size-3" /> {t('lbl.addTable')}
|
||||
</Button>
|
||||
)}
|
||||
{sel != null && (
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs text-danger" onClick={removeSelected}>
|
||||
<Trash2 className="size-3" /> {t('lbl.removeEl')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground text-center max-w-sm leading-relaxed">
|
||||
{t('lbl.intro')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* right: properties of the selection */}
|
||||
{tpl && (
|
||||
<div className="w-72 border-l border-border overflow-y-auto p-3 space-y-3 shrink-0">
|
||||
{!e ? (
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">{t('lbl.selectHint')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(`lbl.el_${e.type}` as any)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{numField('X mm', e.x_mm, (v) => patchEl(sel!, { x_mm: v }))}
|
||||
{numField('Y mm', e.y_mm, (v) => patchEl(sel!, { y_mm: v }))}
|
||||
{(e.type === 'text' || e.type === 'line' || e.type === 'qso_table') &&
|
||||
numField('W mm', e.w_mm ?? 0, (v) => patchEl(sel!, { w_mm: v }))}
|
||||
</div>
|
||||
|
||||
{e.type === 'text' && (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('lbl.text')}</Label>
|
||||
<Input className="h-7 text-xs" value={e.text ?? ''} onChange={(ev) => patchEl(sel!, { text: ev.target.value })} />
|
||||
</div>
|
||||
<VarPicker onPick={(v) => patchEl(sel!, { text: `${e.text ?? ''}<${v}>` })} t={t} />
|
||||
<div className="flex gap-2 items-end">
|
||||
{numField('pt', e.size_pt ?? 9, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')}
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer pb-1.5">
|
||||
<Checkbox checked={!!e.bold} onCheckedChange={(c) => patchEl(sel!, { bold: !!c })} /> B
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs italic cursor-pointer pb-1.5">
|
||||
<Checkbox checked={!!e.italic} onCheckedChange={(c) => patchEl(sel!, { italic: !!c })} /> I
|
||||
</label>
|
||||
<Select value={e.align ?? 'left'} onValueChange={(v) => patchEl(sel!, { align: v as any })}>
|
||||
<SelectTrigger className="h-7 text-xs w-24"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">{t('lbl.alignLeft')}</SelectItem>
|
||||
<SelectItem value="center">{t('lbl.alignCenter')}</SelectItem>
|
||||
<SelectItem value="right">{t('lbl.alignRight')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{e.type === 'line' && numField(t('lbl.thickness'), e.thickness_mm ?? 0.3, (v) => patchEl(sel!, { thickness_mm: v }), 0.1)}
|
||||
|
||||
{e.type === 'addr_block' && (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('lbl.addrLines')}</Label>
|
||||
<textarea
|
||||
className="w-full h-28 rounded-md border border-input bg-background px-2 py-1 text-xs font-mono"
|
||||
value={(e.lines ?? []).join('\n')}
|
||||
onChange={(ev) => patchEl(sel!, { lines: ev.target.value.split('\n') })} />
|
||||
<div className="text-[10px] text-muted-foreground">{t('lbl.addrHint')}</div>
|
||||
</div>
|
||||
<VarPicker onPick={(v) => patchEl(sel!, { lines: [...(e.lines ?? []), `<${v}>`] })} t={t} />
|
||||
<div className="flex gap-2">
|
||||
{numField('pt', e.size_pt ?? 11, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')}
|
||||
{numField(t('lbl.lineGap'), e.line_gap_mm ?? 1.5, (v) => patchEl(sel!, { line_gap_mm: v }), 0.1, 'w-20')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{e.type === 'qso_table' && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{numField(t('lbl.rows'), e.rows_max ?? 4, (v) => patchEl(sel!, { rows_max: Math.max(1, Math.round(v)) }), 1, 'w-16')}
|
||||
{numField(t('lbl.rowH'), e.row_h_mm ?? 4, (v) => patchEl(sel!, { row_h_mm: v }), 0.1, 'w-20')}
|
||||
{numField('pt', e.size_pt ?? 7.5, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={!!e.header} onCheckedChange={(c) => patchEl(sel!, { header: !!c })} />
|
||||
{t('lbl.header')}
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('lbl.columns')}</Label>
|
||||
{(e.columns ?? []).map((c, ci) => (
|
||||
<div key={ci} className="flex items-center gap-1">
|
||||
<Select value={c.field} onValueChange={(v) => {
|
||||
const f = TABLE_FIELDS.find((x) => x.field === v);
|
||||
const columns = e.columns!.map((x, i) => (i === ci ? { ...x, field: v, label: f?.label ?? v } : x));
|
||||
patchEl(sel!, { columns });
|
||||
}}>
|
||||
<SelectTrigger className="h-6 text-[11px] flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TABLE_FIELDS.map((f) => <SelectItem key={f.field} value={f.field}>{f.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="number" step={0.5} className="h-6 w-14 text-[11px] font-mono" value={c.w_mm}
|
||||
onChange={(ev) => {
|
||||
const columns = e.columns!.map((x, i) => (i === ci ? { ...x, w_mm: parseFloat(ev.target.value) || 1 } : x));
|
||||
patchEl(sel!, { columns });
|
||||
}} />
|
||||
<button type="button" onClick={() => patchEl(sel!, { columns: e.columns!.filter((_, i) => i !== ci) })}>
|
||||
<Trash2 className="size-3 text-muted-foreground/60" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" variant="outline" className="h-6 text-[11px] w-full"
|
||||
onClick={() => patchEl(sel!, { columns: [...(e.columns ?? []), { ...TABLE_FIELDS[0] }] })}>
|
||||
<Plus className="size-3" /> {t('lbl.addColumn')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// VarPicker inserts a <VARIABLE> — a menu beats remembering the exact spelling.
|
||||
function VarPicker({ onPick, t }: { onPick: (v: string) => void; t: (k: string) => string }) {
|
||||
return (
|
||||
<Select value="" onValueChange={(v) => { if (v) onPick(v); }}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder={t('lbl.insertVar')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{LABEL_VARS.map((v) => <SelectItem key={v} value={v}>{`<${v}>`}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
// Label printing — the paper-QSL labelling session, in three steps:
|
||||
//
|
||||
// 1. PICK the contacts (preloaded with the R/Q paper queue), grouped by
|
||||
// callsign — several QSOs of the same station share one card.
|
||||
// 2. REVIEW each recipient: routing (direct / bureau / via manager), the
|
||||
// address checked and edited by hand, a QRZ fetch to fill it.
|
||||
// 3. PRINT ONE PDF holding every label of the session (each page at its
|
||||
// own physical size), opened straight in the system viewer — the
|
||||
// operator prints from there — then mark the contacts sent
|
||||
// (date + via) in the log.
|
||||
//
|
||||
// The pages are rasterised by the designer's own renderer at the stock's dpi:
|
||||
// what the designer previewed is what the PDF carries.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { X, Printer, RefreshCw, ChevronRight, ChevronLeft, Check, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
LabelPaperQueue, LabelListStocks, LabelListTemplates, LabelGetTemplate,
|
||||
LabelOpenPDF, LookupCallsignFresh, BulkUpdateQSL, GetActiveProfile,
|
||||
} from '../../../wailsjs/go/main/App';
|
||||
import type { LabelSample, LabelStock, LabelTemplate } from './labelTypes';
|
||||
import { rasterize } from './labelRender';
|
||||
|
||||
interface Props { open: boolean; onClose: () => void }
|
||||
|
||||
type Routing = 'direct' | 'bureau' | 'via';
|
||||
|
||||
interface Station {
|
||||
call: string;
|
||||
qsos: any[]; // raw QSO rows, newest first
|
||||
checked: boolean;
|
||||
routing: Routing;
|
||||
via: string; // manager callsign when routing = via
|
||||
address: string; // multiline, the text that will be printed — verbatim
|
||||
// Whose address the box holds: the DX's prefill, the manager's fetch, or the
|
||||
// operator's own edit. Routing changes recompute the first two and must never
|
||||
// touch the third — an address typed by hand is not the app's to replace.
|
||||
addrFor: 'dx' | 'mgr' | 'user' | 'none';
|
||||
fetching?: boolean;
|
||||
}
|
||||
|
||||
interface TplInfo { id: number; name: string; kind: string; stock_id: number; is_default: boolean }
|
||||
|
||||
// One label's worth of QSO rows, in the designer's sample shape.
|
||||
function toSample(q: any): LabelSample {
|
||||
const d = new Date(q.qso_date);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return {
|
||||
callsign: q.callsign ?? '',
|
||||
qso_date: `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`,
|
||||
time_on: `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`,
|
||||
band: q.band ?? '', mode: q.mode ?? '',
|
||||
freq: q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : '',
|
||||
rst_sent: q.rst_sent ?? '', rst_rcvd: q.rst_rcvd ?? '',
|
||||
name: q.name ?? '', qth: q.qth ?? '', country: q.country ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// dedupeLines drops empties and any line already CONTAINED in an earlier one:
|
||||
// the log often holds "20370 Casablanca Morocco" as the address AND "20370
|
||||
// CASABLANCA" as the QTH AND "Morocco" as the country, and printing all three
|
||||
// stacks the same city three times on the envelope.
|
||||
function dedupeLines(lines: Array<any>): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of lines) {
|
||||
const l = String(raw ?? '').trim();
|
||||
if (!l) continue;
|
||||
const low = l.toLowerCase();
|
||||
if (out.some((prev) => prev.toLowerCase().includes(low))) continue;
|
||||
out.push(l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function initialAddress(q: any): string {
|
||||
return dedupeLines([q.name, q.address, q.qth, q.country]).join('\n');
|
||||
}
|
||||
|
||||
export function LabelPrintModal({ open, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [step, setStep] = useState<'pick' | 'review' | 'print'>('pick');
|
||||
const [stations, setStations] = useState<Station[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [stocks, setStocks] = useState<LabelStock[]>([]);
|
||||
const [tpls, setTpls] = useState<TplInfo[]>([]);
|
||||
const [doQso, setDoQso] = useState(true);
|
||||
const [doAddr, setDoAddr] = useState(true);
|
||||
const [doReturn, setDoReturn] = useState(false);
|
||||
const [qsoTplId, setQsoTplId] = useState(0);
|
||||
const [addrTplId, setAddrTplId] = useState(0);
|
||||
const [retTplId, setRetTplId] = useState(0);
|
||||
const [myAddress, setMyAddress] = useState('');
|
||||
const [myVars, setMyVars] = useState<Record<string, string>>({});
|
||||
|
||||
// Rasterised pages per kind — kept separate for the previews, concatenated
|
||||
// (with each page's own mm size) into the single PDF.
|
||||
type Page = { png: string; w_mm: number; h_mm: number };
|
||||
const [pages, setPages] = useState<{ qso: Page[]; addr: Page[]; ret: Page[] }>({ qso: [], addr: [], ret: [] });
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [pdfPath, setPdfPath] = useState('');
|
||||
const [markDate, setMarkDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [marked, setMarked] = useState(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const rows: any[] = (await LabelPaperQueue()) ?? [];
|
||||
// Group by callsign, newest first inside each group.
|
||||
const by = new Map<string, any[]>();
|
||||
for (const q of rows) {
|
||||
const c = String(q.callsign ?? '').toUpperCase();
|
||||
if (!by.has(c)) by.set(c, []);
|
||||
by.get(c)!.push(q);
|
||||
}
|
||||
setStations([...by.entries()].map(([call, qsos]) => {
|
||||
const via = String(qsos[0]?.qsl_via ?? '').trim();
|
||||
// Via a manager, the DX's own address is exactly the wrong thing to
|
||||
// print on the envelope — start empty and let the QRZ fetch fill in the
|
||||
// MANAGER's.
|
||||
const address = via ? '' : initialAddress(qsos[0]);
|
||||
return {
|
||||
call, qsos, checked: true,
|
||||
routing: via ? 'via' as Routing : (address.split('\n').length >= 3 ? 'direct' as Routing : 'bureau' as Routing),
|
||||
via, address, addrFor: (via ? 'none' : 'dx') as Station['addrFor'],
|
||||
};
|
||||
}));
|
||||
const st = (await LabelListStocks()) as any as LabelStock[];
|
||||
setStocks(st);
|
||||
const tl = ((await LabelListTemplates()) ?? []) as any as TplInfo[];
|
||||
setTpls(tl);
|
||||
const def = (kind: string) => tl.find((x) => x.kind === kind && x.is_default) ?? tl.find((x) => x.kind === kind);
|
||||
setQsoTplId(def('qso')?.id ?? 0);
|
||||
setAddrTplId(def('address')?.id ?? 0);
|
||||
setRetTplId(def('address')?.id ?? 0);
|
||||
const p: any = await GetActiveProfile().catch(() => null);
|
||||
const mv = {
|
||||
MYCALL: p?.callsign ?? '', MYNAME: p?.op_name ?? p?.operator ?? '',
|
||||
MYSTREET: p?.my_street ?? '', MYZIP: p?.my_postal_code ?? '',
|
||||
MYCITY: p?.my_city ?? '', MYCOUNTRY: p?.my_country ?? '',
|
||||
};
|
||||
setMyVars(mv);
|
||||
setMyAddress([mv.MYNAME && `${mv.MYNAME} · ${mv.MYCALL}` || mv.MYCALL, mv.MYSTREET,
|
||||
[mv.MYZIP, mv.MYCITY].filter(Boolean).join(' '), mv.MYCOUNTRY]
|
||||
.map((x) => String(x ?? '').trim()).filter(Boolean).join('\n'));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep('pick'); setPages({ qso: [], addr: [], ret: [] }); setPdfPath(''); setMarked(0);
|
||||
void load();
|
||||
}, [open, load]);
|
||||
|
||||
const patchStation = (i: number, p: Partial<Station>) =>
|
||||
setStations((l) => l.map((s, j) => (j === i ? { ...s, ...p } : s)));
|
||||
|
||||
async function fetchAddress(i: number) {
|
||||
const s = stations[i];
|
||||
const target = s.routing === 'via' && s.via.trim() ? s.via.trim().toUpperCase() : s.call;
|
||||
patchStation(i, { fetching: true });
|
||||
try {
|
||||
const r: any = await LookupCallsignFresh(target, '');
|
||||
// A postal address needs a STREET (or at least a name and a town). A
|
||||
// lookup that fell back to cty.dat answers with a country alone —
|
||||
// overwriting a reviewed address with a bare country is strictly worse
|
||||
// than saying nothing was found, and losing the address is what was
|
||||
// reported.
|
||||
const street = String(r?.address ?? '').trim();
|
||||
if (!street && !(String(r?.name ?? '').trim() && String(r?.qth ?? '').trim())) {
|
||||
patchStation(i, { fetching: false });
|
||||
setError(t('lpr.noAddress', { call: target }));
|
||||
return;
|
||||
}
|
||||
const lines = dedupeLines([
|
||||
r?.name, street,
|
||||
[r?.zip, r?.qth].filter(Boolean).join(' '),
|
||||
r?.country,
|
||||
]);
|
||||
patchStation(i, {
|
||||
address: lines.join('\n'), fetching: false,
|
||||
addrFor: target === s.call ? 'dx' : 'mgr',
|
||||
});
|
||||
} catch (e: any) {
|
||||
patchStation(i, { fetching: false });
|
||||
setError(String(e?.message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
const picked = stations.filter((s) => s.checked);
|
||||
const needAddress = picked.filter((s) => s.routing !== 'bureau');
|
||||
|
||||
// ── step 3: build the pages ───────────────────────────────────────────
|
||||
async function buildPages() {
|
||||
setBuilding(true); setError('');
|
||||
try {
|
||||
const getTpl = async (id: number): Promise<{ tpl: LabelTemplate; stock: LabelStock } | null> => {
|
||||
if (!id) return null;
|
||||
const tpl = JSON.parse(await LabelGetTemplate(id)) as LabelTemplate;
|
||||
const stock = stocks.find((x) => x.id === tpl.stock_id) ?? stocks[0];
|
||||
return stock ? { tpl, stock } : null;
|
||||
};
|
||||
const out = { qso: [] as Page[], addr: [] as Page[], ret: [] as Page[] };
|
||||
const page = (png: string, stock: LabelStock): Page => ({ png, w_mm: stock.w_mm, h_mm: stock.h_mm });
|
||||
if (doQso) {
|
||||
const got = await getTpl(qsoTplId);
|
||||
if (!got) throw new Error(t('lpr.noQsoTpl'));
|
||||
const table = got.tpl.elements.find((e) => e.type === 'qso_table');
|
||||
const per = Math.max(1, table?.rows_max ?? 4);
|
||||
for (const s of picked) {
|
||||
const vars = { CALL: s.call, NAME: s.qsos[0]?.name ?? '', QTH: s.qsos[0]?.qth ?? '', COUNTRY: s.qsos[0]?.country ?? '', VIA: s.via, ...myVars };
|
||||
for (let i = 0; i < s.qsos.length; i += per) {
|
||||
out.qso.push(page(rasterize(got.tpl, got.stock, { vars, qsos: s.qsos.slice(i, i + per).map(toSample) }), got.stock));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (doAddr) {
|
||||
const got = await getTpl(addrTplId);
|
||||
if (!got) throw new Error(t('lpr.noAddrTpl'));
|
||||
for (const s of needAddress) {
|
||||
// The REVIEWED text wins: the template's address block prints these
|
||||
// lines verbatim — that is what "check the address first" means.
|
||||
const tpl: LabelTemplate = {
|
||||
...got.tpl,
|
||||
elements: got.tpl.elements.map((e) =>
|
||||
e.type === 'addr_block' ? { ...e, lines: s.address.split('\n') } : e),
|
||||
};
|
||||
const vars = { CALL: s.call, VIA: s.via, ...myVars };
|
||||
out.addr.push(page(rasterize(tpl, got.stock, { vars, qsos: [] }), got.stock));
|
||||
}
|
||||
}
|
||||
if (doReturn) {
|
||||
const got = await getTpl(retTplId);
|
||||
if (!got) throw new Error(t('lpr.noAddrTpl'));
|
||||
const tpl: LabelTemplate = {
|
||||
...got.tpl,
|
||||
elements: got.tpl.elements.map((e) =>
|
||||
e.type === 'addr_block' ? { ...e, lines: myAddress.split('\n') } : e),
|
||||
};
|
||||
// One per envelope that needs a return slip — the direct/via ones.
|
||||
const n = Math.max(1, needAddress.length);
|
||||
const one = page(rasterize(tpl, got.stock, { vars: { ...myVars, CALL: myVars.MYCALL }, qsos: [] }), got.stock);
|
||||
for (let i = 0; i < n; i++) out.ret.push(one);
|
||||
}
|
||||
setPages(out);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
setBuilding(false);
|
||||
}
|
||||
|
||||
const allPages = [...pages.qso, ...pages.addr, ...pages.ret];
|
||||
|
||||
async function openPdf() {
|
||||
try {
|
||||
const path = await LabelOpenPDF(allPages as any);
|
||||
if (path) setPdfPath(path as string);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function markSent() {
|
||||
try {
|
||||
const date = markDate.replace(/-/g, '');
|
||||
let n = 0;
|
||||
const groups: Array<[Station[], string]> = [
|
||||
[picked.filter((s) => s.routing === 'bureau'), 'B'],
|
||||
[picked.filter((s) => s.routing !== 'bureau'), 'D'],
|
||||
];
|
||||
for (const [list, via] of groups) {
|
||||
const ids = list.flatMap((s) => s.qsos.map((q) => q.id));
|
||||
if (ids.length === 0) continue;
|
||||
n += (await BulkUpdateQSL(ids, { sent_status: 'Y', sent_date: date, via, rcvd_status: '', rcvd_date: '', rcvd_via: '', notes: '', comment: '' } as any)) as number;
|
||||
}
|
||||
setMarked(n);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const kindBlock = (kind: 'qso' | 'addr' | 'ret', title: string) => {
|
||||
const pg = pages[kind];
|
||||
if (pg.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('lpr.pageCount', { n: pg.length })}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{pg.slice(0, 8).map((p, i) => (
|
||||
<img key={i} src={p.png} className="h-20 border border-border rounded-sm bg-white shrink-0" />
|
||||
))}
|
||||
{pg.length > 8 && <span className="text-xs text-muted-foreground self-center">+{pg.length - 8}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const sumQsos = picked.reduce((a, s) => a + s.qsos.length, 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-5xl h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Printer className="size-4 text-primary" />
|
||||
<span className="font-semibold text-sm">{t('lpr.title')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step === 'pick' ? t('lpr.step1') : step === 'review' ? t('lpr.step2') : t('lpr.step3')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onClose}><X className="size-4" /></Button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-1.5 text-xs text-danger border-b border-border/60">{error}</div>}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> …
|
||||
</div>
|
||||
) : step === 'pick' ? (
|
||||
stations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center pt-16 max-w-md mx-auto leading-relaxed">
|
||||
{t('lpr.emptyQueue')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 pb-1 text-xs text-muted-foreground">
|
||||
<button type="button" className="underline underline-offset-2" onClick={() => setStations((l) => l.map((s) => ({ ...s, checked: true })))}>{t('lpr.all')}</button>
|
||||
<button type="button" className="underline underline-offset-2" onClick={() => setStations((l) => l.map((s) => ({ ...s, checked: false })))}>{t('lpr.none')}</button>
|
||||
<div className="flex-1" />
|
||||
<span>{t('lpr.pickSummary', { s: picked.length, q: sumQsos })}</span>
|
||||
</div>
|
||||
{stations.map((s, i) => (
|
||||
<label key={s.call} className="flex items-center gap-2.5 rounded-md border border-border/60 px-2.5 py-1.5 text-sm cursor-pointer hover:bg-muted/40">
|
||||
<Checkbox checked={s.checked} onCheckedChange={(c) => patchStation(i, { checked: !!c })} />
|
||||
<span className="font-mono font-bold w-28">{s.call}</span>
|
||||
<span className="text-xs text-muted-foreground w-16">{s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-xs text-muted-foreground flex-1 truncate">
|
||||
{s.qsos.map((q) => `${q.band ?? ''} ${q.mode ?? ''}`).slice(0, 5).join(' · ')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-40">{s.qsos[0]?.country ?? ''}</span>
|
||||
{s.via && <span className="text-[10px] px-1.5 rounded bg-info-muted text-info-muted-foreground">via {s.via}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : step === 'review' ? (
|
||||
<div className="space-y-2">
|
||||
{picked.map((s) => {
|
||||
const i = stations.indexOf(s);
|
||||
return (
|
||||
<div key={s.call} className="rounded-lg border border-border p-2.5 flex gap-3">
|
||||
<div className="w-64 shrink-0 space-y-1.5">
|
||||
<div className="font-mono font-bold">{s.call}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">{s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<Select value={s.routing} onValueChange={(v) => {
|
||||
const routing = v as Routing;
|
||||
const patch: Partial<Station> = { routing };
|
||||
if (s.addrFor !== 'user') {
|
||||
// The box follows the routing while the operator has
|
||||
// not typed in it: via → empty (fetch the manager),
|
||||
// direct → the DX's own prefill.
|
||||
patch.address = routing === 'via' ? '' : initialAddress(s.qsos[0]);
|
||||
patch.addrFor = routing === 'via' ? 'none' : 'dx';
|
||||
}
|
||||
patchStation(i, patch);
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="direct">{t('lpr.direct')}</SelectItem>
|
||||
<SelectItem value="bureau">{t('lpr.bureau')}</SelectItem>
|
||||
<SelectItem value="via">{t('lpr.viaMgr')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{s.routing === 'via' && (
|
||||
<Input className="h-7 text-xs font-mono uppercase" placeholder={t('lpr.mgrPh')}
|
||||
value={s.via} onChange={(ev) => patchStation(i, { via: ev.target.value })} />
|
||||
)}
|
||||
{s.routing !== 'bureau' && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs w-full"
|
||||
disabled={s.fetching}
|
||||
onClick={() => void fetchAddress(i)}>
|
||||
{s.fetching ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{t('lpr.fetchQrz')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{s.routing === 'bureau' ? (
|
||||
<div className="flex-1 text-xs text-muted-foreground self-center">{t('lpr.bureauHint')}</div>
|
||||
) : (
|
||||
<textarea
|
||||
className={cn('flex-1 rounded-md border bg-background px-2 py-1 text-sm font-mono',
|
||||
s.address.trim() ? 'border-input' : 'border-danger')}
|
||||
rows={4}
|
||||
placeholder={s.routing === 'via' ? t('lpr.mgrAddressPh') : t('lpr.addressPh')}
|
||||
value={s.address}
|
||||
onChange={(ev) => patchStation(i, { address: ev.target.value, addrFor: 'user' })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* what to print */}
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('lpr.whatToPrint')}</div>
|
||||
{([
|
||||
['qso', doQso, setDoQso, qsoTplId, setQsoTplId, 'qso', t('lpr.kQso')],
|
||||
['addr', doAddr, setDoAddr, addrTplId, setAddrTplId, 'address', t('lpr.kAddr')],
|
||||
['ret', doReturn, setDoReturn, retTplId, setRetTplId, 'address', t('lpr.kRet')],
|
||||
] as const).map(([key, on, setOn, tplId, setTplId, kind, label]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer w-64">
|
||||
<Checkbox checked={on} onCheckedChange={(c) => (setOn as any)(!!c)} /> {label}
|
||||
</label>
|
||||
<Select value={String(tplId || '')} onValueChange={(v) => (setTplId as any)(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-7 text-xs w-72"><SelectValue placeholder={t('lpr.pickTpl')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{tpls.filter((x) => x.kind === kind).map((x) => (
|
||||
<SelectItem key={x.id} value={String(x.id)}>{x.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
{doReturn && (
|
||||
<div className="flex items-start gap-2 pl-6">
|
||||
<span className="text-xs text-muted-foreground pt-1 w-24">{t('lpr.myAddress')}</span>
|
||||
<textarea className="flex-1 max-w-96 rounded-md border border-input bg-background px-2 py-1 text-xs font-mono" rows={4}
|
||||
value={myAddress} onChange={(ev) => setMyAddress(ev.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => void buildPages()} disabled={building || (!doQso && !doAddr && !doReturn)}>
|
||||
{building ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
||||
{t('lpr.build')}
|
||||
</Button>
|
||||
</div>
|
||||
{kindBlock('qso', t('lpr.kQso'))}
|
||||
{kindBlock('addr', t('lpr.kAddr'))}
|
||||
{kindBlock('ret', t('lpr.kRet'))}
|
||||
{allPages.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" className="h-8" onClick={() => void openPdf()}>
|
||||
<Printer className="size-3.5" /> {t('lpr.openPdf', { n: allPages.length })}
|
||||
</Button>
|
||||
{pdfPath && <span className="text-xs text-success flex items-center gap-1"><Check className="size-3.5" />{pdfPath}</span>}
|
||||
</div>
|
||||
)}
|
||||
{/* mark as sent */}
|
||||
{(pages.qso.length > 0 || pages.addr.length > 0) && (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('lpr.markTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('lpr.markHint')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={markDate} onChange={(ev) => setMarkDate(ev.target.value)} />
|
||||
<Button size="sm" className="h-8" onClick={() => void markSent()} disabled={marked > 0}>
|
||||
<Check className="size-3.5" /> {t('lpr.markBtn', { n: sumQsos })}
|
||||
</Button>
|
||||
{marked > 0 && <span className="text-xs text-success">{t('lpr.markDone', { n: marked })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* footer nav */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-t border-border shrink-0">
|
||||
{step !== 'pick' && (
|
||||
<Button variant="outline" size="sm" className="h-8"
|
||||
onClick={() => setStep(step === 'print' ? 'review' : 'pick')}>
|
||||
<ChevronLeft className="size-3.5" /> {t('lpr.back')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{step === 'pick' && (
|
||||
<Button size="sm" className="h-8" disabled={picked.length === 0} onClick={() => setStep('review')}>
|
||||
{t('lpr.next')} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{step === 'review' && (
|
||||
<Button size="sm" className="h-8"
|
||||
disabled={needAddress.some((s) => !s.address.trim())}
|
||||
title={needAddress.some((s) => !s.address.trim()) ? t('lpr.missingAddr') : undefined}
|
||||
onClick={() => setStep('print')}>
|
||||
{t('lpr.next')} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// labelRender draws a label template onto a canvas. It is THE renderer: the
|
||||
// designer's preview and (later) the pages rasterised into the print PDF both
|
||||
// come through here, which is what makes the preview trustworthy — there is no
|
||||
// second implementation to disagree with it.
|
||||
//
|
||||
// The canvas is scaled so that 1 mm = `scale` px; print passes dpi/25.4, the
|
||||
// preview passes whatever fits its box. All layout maths stays in mm.
|
||||
|
||||
import type { LabelElement, LabelSample, LabelStock, LabelTemplate } from './labelTypes';
|
||||
|
||||
export interface RenderData {
|
||||
// Variable values for text/address elements (<CALL> → value). Missing keys
|
||||
// render as the bare <NAME> so the designer can SEE an unresolved variable.
|
||||
vars: Record<string, string>;
|
||||
// Rows for the qso_table element.
|
||||
qsos: LabelSample[];
|
||||
}
|
||||
|
||||
export interface ElementBox { x: number; y: number; w: number; h: number } // mm
|
||||
|
||||
const PT_TO_MM = 25.4 / 72;
|
||||
|
||||
// resolveVars substitutes <VAR> markers, collapsing to '' only when the value
|
||||
// is known-empty; unknown markers stay visible on purpose.
|
||||
export function resolveVars(text: string, vars: Record<string, string>): string {
|
||||
return text.replace(/<([A-Z_]+)>/g, (m, k) => (k in vars ? vars[k] : m));
|
||||
}
|
||||
|
||||
// render draws the whole label and returns each element's bounding box in mm —
|
||||
// the editor's hit-testing works off these, so a drag grabs exactly what was
|
||||
// painted, table rows included.
|
||||
export function render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
t: LabelTemplate,
|
||||
stock: LabelStock,
|
||||
data: RenderData,
|
||||
scale: number,
|
||||
): ElementBox[] {
|
||||
const W = stock.w_mm, H = stock.h_mm;
|
||||
ctx.save();
|
||||
ctx.scale(scale, scale);
|
||||
// The physical label: white, whatever the app theme — this is paper.
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#000';
|
||||
|
||||
// The canvas thinks in mm from here on; fonts are set per element in pt and
|
||||
// drawn with an unscaled-pt trick: setTransform back to px for text quality.
|
||||
const boxes: ElementBox[] = [];
|
||||
for (const e of t.elements) {
|
||||
boxes.push(drawElement(ctx, e, t, data, scale));
|
||||
}
|
||||
ctx.restore();
|
||||
return boxes;
|
||||
}
|
||||
|
||||
// drawMargins paints the unprintable border as a dashed guide — editor only,
|
||||
// never part of a print rasterisation.
|
||||
export function drawMargins(ctx: CanvasRenderingContext2D, stock: LabelStock, scale: number): void {
|
||||
ctx.save();
|
||||
ctx.scale(scale, scale);
|
||||
ctx.strokeStyle = 'rgba(80,140,255,0.55)';
|
||||
ctx.lineWidth = 0.15;
|
||||
ctx.setLineDash([1.2, 1.2]);
|
||||
ctx.strokeRect(
|
||||
stock.margin_left_mm, stock.margin_top_mm,
|
||||
stock.w_mm - stock.margin_left_mm - stock.margin_right_mm,
|
||||
stock.h_mm - stock.margin_top_mm - stock.margin_bottom_mm,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawText(
|
||||
ctx: CanvasRenderingContext2D, text: string, xMm: number, yTopMm: number,
|
||||
wMm: number | undefined, sizePt: number, scale: number,
|
||||
opts: { bold?: boolean; italic?: boolean; align?: string; face?: string },
|
||||
): number {
|
||||
// Text is drawn in PX space (resetting the mm scale) so the browser rasterises
|
||||
// the font at device resolution instead of scaling a 1-mm-tall glyph up.
|
||||
const hMm = sizePt * PT_TO_MM;
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
// The font is specified in px = (pt → mm) × scale, so a 10 pt line measures
|
||||
// exactly 10 pt on the printed label whatever the preview zoom is.
|
||||
ctx.font = `${opts.italic ? 'italic ' : ''}${opts.bold ? 'bold ' : ''}${hMm * scale}px ${opts.face && opts.face.trim() ? opts.face : 'Arial, Helvetica, sans-serif'}`;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textBaseline = 'top';
|
||||
let x = xMm * scale;
|
||||
if (wMm && opts.align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
x = (xMm + wMm / 2) * scale;
|
||||
} else if (wMm && opts.align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
x = (xMm + wMm) * scale;
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
ctx.fillText(text, x, yTopMm * scale);
|
||||
ctx.restore();
|
||||
return hMm;
|
||||
}
|
||||
|
||||
function drawElement(
|
||||
ctx: CanvasRenderingContext2D, e: LabelElement, t: LabelTemplate,
|
||||
data: RenderData, scale: number,
|
||||
): ElementBox {
|
||||
switch (e.type) {
|
||||
case 'text': {
|
||||
const size = e.size_pt || 9;
|
||||
const text = resolveVars(e.text ?? '', data.vars);
|
||||
drawText(ctx, text, e.x_mm, e.y_mm, e.w_mm, size, scale,
|
||||
{ bold: e.bold, italic: e.italic, align: e.align, face: t.font });
|
||||
const h = size * PT_TO_MM * 1.25;
|
||||
return { x: e.x_mm, y: e.y_mm, w: e.w_mm || Math.max(20, text.length * size * PT_TO_MM * 0.55), h };
|
||||
}
|
||||
case 'line': {
|
||||
const th = e.thickness_mm || 0.3;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(e.x_mm, e.y_mm, e.w_mm || 10, th);
|
||||
// A hairline is a 0.3 mm target nobody can grab; the box pads it.
|
||||
return { x: e.x_mm, y: e.y_mm - 0.8, w: e.w_mm || 10, h: th + 1.6 };
|
||||
}
|
||||
case 'addr_block': {
|
||||
const size = e.size_pt || 11;
|
||||
const gap = e.line_gap_mm ?? 1.5;
|
||||
// Collapse-empty is the point of the block: a missing street must pull
|
||||
// the city UP, not leave a hole in the middle of an address.
|
||||
const lines = (e.lines ?? [])
|
||||
.map((l) => resolveVars(l, data.vars).trim())
|
||||
.filter((l) => l !== '');
|
||||
let y = e.y_mm;
|
||||
let maxW = 0;
|
||||
for (const l of lines) {
|
||||
const h = drawText(ctx, l, e.x_mm, y, undefined, size, scale,
|
||||
{ bold: e.bold, italic: e.italic, face: t.font });
|
||||
y += h + gap;
|
||||
maxW = Math.max(maxW, l.length * size * PT_TO_MM * 0.55);
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: Math.max(20, maxW), h: Math.max(4, y - e.y_mm) };
|
||||
}
|
||||
case 'qso_table': {
|
||||
const cols = e.columns ?? [];
|
||||
const size = e.size_pt || 7.5;
|
||||
const rowH = e.row_h_mm || 4;
|
||||
const wTot = cols.reduce((a, c) => a + c.w_mm, 0);
|
||||
let y = e.y_mm;
|
||||
if (e.header) {
|
||||
let x = e.x_mm;
|
||||
for (const c of cols) {
|
||||
drawText(ctx, c.label, x + 0.6, y + (rowH - size * PT_TO_MM) / 2, c.w_mm - 1.2, size, scale,
|
||||
{ bold: true, align: c.align, face: t.font });
|
||||
x += c.w_mm;
|
||||
}
|
||||
// Rule under the header, not a full grid: on a 29 mm label the grid IS
|
||||
// the noise.
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(e.x_mm, y + rowH - 0.25, wTot, 0.25);
|
||||
y += rowH;
|
||||
}
|
||||
const rows = data.qsos.slice(0, e.rows_max || 4);
|
||||
for (const q of rows) {
|
||||
let x = e.x_mm;
|
||||
for (const c of cols) {
|
||||
const v = String((q as any)[c.field] ?? '');
|
||||
drawText(ctx, v, x + 0.6, y + (rowH - size * PT_TO_MM) / 2, c.w_mm - 1.2, size, scale,
|
||||
{ align: c.align, face: t.font });
|
||||
x += c.w_mm;
|
||||
}
|
||||
y += rowH;
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: wTot, h: y - e.y_mm };
|
||||
}
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: 10, h: 4 };
|
||||
}
|
||||
|
||||
// rasterize renders one label at the stock's dpi and returns a PNG data URL —
|
||||
// the pages handed to the PDF exporter. Same renderer as the preview, only the
|
||||
// scale changes, which is the whole guarantee of the feature.
|
||||
export function rasterize(
|
||||
t: LabelTemplate, stock: LabelStock, data: RenderData,
|
||||
): string {
|
||||
const scale = (stock.dpi || 300) / 25.4; // px per mm
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.round(stock.w_mm * scale);
|
||||
cv.height = Math.round(stock.h_mm * scale);
|
||||
const ctx = cv.getContext('2d')!;
|
||||
render(ctx, t, stock, data, scale);
|
||||
return cv.toDataURL('image/png');
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// TypeScript mirror of the label template schema (v1) defined in
|
||||
// internal/labels/labels.go. Documents cross the Wails boundary as JSON
|
||||
// strings; these types are the frontend's contract with that schema.
|
||||
//
|
||||
// Everything is in MILLIMETRES — pixels only exist inside labelRender, at the
|
||||
// stock's dpi. See the Go package comment for why.
|
||||
|
||||
export interface LabelStock {
|
||||
id?: number;
|
||||
name: string;
|
||||
w_mm: number;
|
||||
h_mm: number;
|
||||
margin_top_mm: number;
|
||||
margin_right_mm: number;
|
||||
margin_bottom_mm: number;
|
||||
margin_left_mm: number;
|
||||
dpi: number;
|
||||
}
|
||||
|
||||
export interface LabelColumn {
|
||||
field: string;
|
||||
label: string;
|
||||
w_mm: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export interface LabelElement {
|
||||
type: 'text' | 'line' | 'qso_table' | 'addr_block';
|
||||
x_mm: number;
|
||||
y_mm: number;
|
||||
w_mm?: number;
|
||||
// text
|
||||
text?: string;
|
||||
size_pt?: number;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
// line
|
||||
thickness_mm?: number;
|
||||
// qso_table
|
||||
columns?: LabelColumn[];
|
||||
rows_max?: number;
|
||||
row_h_mm?: number;
|
||||
header?: boolean;
|
||||
// addr_block
|
||||
lines?: string[];
|
||||
line_gap_mm?: number;
|
||||
}
|
||||
|
||||
export interface LabelTemplate {
|
||||
version: 1;
|
||||
kind: 'qso' | 'address';
|
||||
name?: string;
|
||||
stock_id: number;
|
||||
font?: string;
|
||||
elements: LabelElement[];
|
||||
}
|
||||
|
||||
export interface LabelSample {
|
||||
callsign: string; qso_date: string; time_on: string; band: string;
|
||||
freq: string; mode: string; rst_sent: string; rst_rcvd: string;
|
||||
name: string; qth: string; country: string;
|
||||
}
|
||||
|
||||
// The QSO-table columns the designer offers, with default widths that fit their
|
||||
// content at 8 pt — the operator adjusts from there against real data.
|
||||
export const TABLE_FIELDS: { field: string; label: string; w_mm: number }[] = [
|
||||
{ field: 'qso_date', label: 'Date', w_mm: 17 },
|
||||
{ field: 'time_on', label: 'UTC', w_mm: 10 },
|
||||
{ field: 'band', label: 'Band', w_mm: 10 },
|
||||
{ field: 'freq', label: 'MHz', w_mm: 13 },
|
||||
{ field: 'mode', label: 'Mode', w_mm: 11 },
|
||||
{ field: 'rst_sent', label: 'RST', w_mm: 9 },
|
||||
{ field: 'rst_rcvd', label: 'RST rx', w_mm: 9 },
|
||||
];
|
||||
|
||||
// The variables a text or address element may carry. Resolution at PRINT time
|
||||
// uses the real QSO/profile; the designer resolves them against the sample so
|
||||
// the preview reads like a finished label.
|
||||
export const LABEL_VARS = [
|
||||
'CALL', 'NAME', 'QTH', 'COUNTRY', 'VIA',
|
||||
'STREET', 'ZIP', 'CITY', 'STATE',
|
||||
'MYCALL', 'MYNAME', 'MYSTREET', 'MYZIP', 'MYCITY', 'MYCOUNTRY',
|
||||
] as const;
|
||||
|
||||
// Starter documents for a fresh template — a usable label, not a blank page:
|
||||
// an empty canvas asks the operator to invent the feature, a finished-looking
|
||||
// starter only asks them to adjust it.
|
||||
export function starterTemplate(kind: 'qso' | 'address', stock: LabelStock): LabelTemplate {
|
||||
const w = stock.w_mm - stock.margin_left_mm - stock.margin_right_mm;
|
||||
const x = stock.margin_left_mm;
|
||||
if (kind === 'qso') {
|
||||
return {
|
||||
version: 1, kind, stock_id: stock.id ?? 0,
|
||||
elements: [
|
||||
{ type: 'text', x_mm: x, y_mm: stock.margin_top_mm + 1, w_mm: w, text: 'To Radio <CALL>', size_pt: 10, bold: true },
|
||||
{ type: 'line', x_mm: x, y_mm: stock.margin_top_mm + 6.2, w_mm: w, thickness_mm: 0.3 },
|
||||
{
|
||||
type: 'qso_table', x_mm: x, y_mm: stock.margin_top_mm + 7.5, w_mm: w,
|
||||
rows_max: Math.max(1, Math.min(5, Math.floor((stock.h_mm - stock.margin_top_mm - stock.margin_bottom_mm - 12) / 4))),
|
||||
row_h_mm: 4, header: true, size_pt: 7.5,
|
||||
columns: TABLE_FIELDS.slice(0, 6).map((c) => ({ ...c })),
|
||||
} as LabelElement,
|
||||
{ type: 'text', x_mm: x, y_mm: stock.h_mm - stock.margin_bottom_mm - 4, w_mm: w, text: 'PSE QSL · 73 de <MYCALL>', size_pt: 8 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1, kind, stock_id: stock.id ?? 0,
|
||||
elements: [
|
||||
{
|
||||
type: 'addr_block', x_mm: x + 2, y_mm: stock.margin_top_mm + 3,
|
||||
lines: ['<NAME> · <CALL>', '<STREET>', '<ZIP> <CITY>', '<COUNTRY>'],
|
||||
size_pt: 11, line_gap_mm: 1.6,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user