feat(labels): the Label Designer — QSO and address labels for paper QSL

The designer's data model lives in internal/labels: STOCKS are the physical
roll in the printer (geometry in mm, margins, dpi — seeded with the common
Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are
one design each, of two kinds: the QSO label glued on the card, whose repeating
table carries several contacts of the same station, and the address label for
the envelope, whose lines collapse when a variable is empty.

Everything is measured in millimetres — labels are sold in mm, and an operator
lining a design up against a physical sticker thinks in mm; pixels exist only in
the renderer, at the stock's dpi. The canvas renderer is shared between the
editor's preview and the future print path, so there is no second
implementation for the preview to disagree with.

The preview is fed with the log's latest contacts rather than lorem ipsum: real
data shows a too-narrow column immediately.

Printing (PDF, one page per label at exact size) is the next module; nothing
here prints yet.
This commit is contained in:
2026-08-28 19:41:15 +02:00
parent 3dc31697cd
commit 1a169fdb4f
16 changed files with 1730 additions and 2 deletions
@@ -0,0 +1,538 @@
// 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;
}
const PREVIEW_MAX_W = 720;
const PREVIEW_MAX_H = 420;
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 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;
return Math.min(PREVIEW_MAX_W / stock.w_mm, PREVIEW_MAX_H / stock.h_mm);
}, [stock]);
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 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>
);
}