Files
OpsLog/frontend/src/components/labels/LabelDesignerModal.tsx
T
rouggy e637f0814d style(labels): fit the preview to the pane
A fixed 720 px cap cut a 98 mm label off at the RST column and wasted half the
window; the scale now follows the centre pane's measured size, with a floor so
small stock never renders unreadably.
2026-08-28 19:46:54 +02:00

556 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}