diff --git a/app.go b/app.go index 98db935..cb48d5d 100644 --- a/app.go +++ b/app.go @@ -44,6 +44,7 @@ import ( "hamlog/internal/gridcache" "hamlog/internal/integrations/udp" "hamlog/internal/kpa" + "hamlog/internal/labels" "hamlog/internal/lookup" "hamlog/internal/lotwusers" "hamlog/internal/netctl" @@ -735,6 +736,7 @@ type App struct { uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened awardRefs *awardref.Repo qslTemplates *qslcard.Repo + labelRepo *labels.Repo // label designer (stocks + templates) operating *operating.Repo udp *udp.Manager udpRepo *udp.Repo @@ -1185,6 +1187,7 @@ func (a *App) startup(ctx context.Context) { } a.awardRefs = awardref.NewRepo(conn) a.qslTemplates = qslcard.NewRepo(conn) + a.labelRepo = labels.NewRepo(conn) a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields) a.seedBuiltinReferences() // first-run: populate built-in award reference lists a.mirrorAwards() // keep /awards/*.json in step with the database diff --git a/app_labels.go b/app_labels.go new file mode 100644 index 0000000..e447818 --- /dev/null +++ b/app_labels.go @@ -0,0 +1,208 @@ +package main + +// Label designer — the Wails boundary for internal/labels. +// +// The designer edits two things: STOCKS (the physical roll in the printer, +// geometry in mm) and TEMPLATES (one design per label kind: the QSO label glued +// on a card, the address label for the envelope). Printing — a later module — +// will ask for the default template of each kind and hand the rasterised pages +// to a PDF; nothing here prints. + +import ( + "fmt" + "strings" + + "hamlog/internal/applog" + "hamlog/internal/labels" + "hamlog/internal/qso" +) + +// LabelTemplateInfo is one row of the designer's template list. +type LabelTemplateInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + StockID int64 `json:"stock_id"` + ProfileID *int64 `json:"profile_id,omitempty"` + IsDefault bool `json:"is_default"` + UpdatedAt string `json:"updated_at"` +} + +// LabelListStocks returns every label stock, seeding the builtin Brother rolls +// on first use. +func (a *App) LabelListStocks() ([]labels.Stock, error) { + if a.labelRepo == nil { + return nil, fmt.Errorf("db not initialized") + } + if err := a.labelRepo.SeedStocks(a.ctx); err != nil { + applog.Printf("labels: seeding stocks failed: %v", err) + } + return a.labelRepo.Stocks(a.ctx) +} + +// LabelSaveStock creates or updates one stock and returns its id. +func (a *App) LabelSaveStock(s labels.Stock) (int64, error) { + if a.labelRepo == nil { + return 0, fmt.Errorf("db not initialized") + } + if err := a.labelRepo.SaveStock(a.ctx, &s); err != nil { + return 0, err + } + return s.ID, nil +} + +// LabelDeleteStock removes a stock; designs pointing at it keep their content. +func (a *App) LabelDeleteStock(id int64) error { + if a.labelRepo == nil { + return fmt.Errorf("db not initialized") + } + return a.labelRepo.DeleteStock(a.ctx, id) +} + +// LabelListTemplates lists the designs visible to the active profile. +func (a *App) LabelListTemplates() ([]LabelTemplateInfo, error) { + if a.labelRepo == nil { + return nil, fmt.Errorf("db not initialized") + } + var recs []labels.Record + var err error + if p, e := a.profiles.Active(a.ctx); e == nil { + recs, err = a.labelRepo.ListFor(a.ctx, p.ID) + } else { + recs, err = a.labelRepo.List(a.ctx) + } + if err != nil { + return nil, err + } + out := make([]LabelTemplateInfo, 0, len(recs)) + for _, r := range recs { + info := LabelTemplateInfo{ + ID: r.ID, Name: r.Name, Kind: r.Kind, ProfileID: r.ProfileID, + IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"), + } + if r.StockID != nil { + info.StockID = *r.StockID + } + out = append(out, info) + } + return out, nil +} + +// LabelGetTemplate returns one stored design document (JSON). +func (a *App) LabelGetTemplate(id int64) (string, error) { + if a.labelRepo == nil { + return "", fmt.Errorf("db not initialized") + } + rec, err := a.labelRepo.Get(a.ctx, id) + if err != nil { + return "", err + } + return rec.JSON, nil +} + +// LabelSaveTemplate validates and stores a design; id 0 creates. Returns the id. +func (a *App) LabelSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) { + if a.labelRepo == nil { + return 0, fmt.Errorf("db not initialized") + } + name = strings.TrimSpace(name) + if name == "" { + return 0, fmt.Errorf("template name required") + } + t, err := labels.Parse([]byte(doc)) + if err != nil { + return 0, err + } + if err := labels.Validate(t); err != nil { + return 0, err + } + rec := labels.Record{ID: id, Name: name, Kind: t.Kind, JSON: doc} + if t.StockID != 0 { + sid := t.StockID + rec.StockID = &sid + } + if forActiveProfile { + if p, err := a.profiles.Active(a.ctx); err == nil { + rec.ProfileID = &p.ID + } + } + if err := a.labelRepo.Save(a.ctx, &rec); err != nil { + return 0, err + } + applog.Printf("labels: template %q (%s) saved (id %d)", name, t.Kind, rec.ID) + return rec.ID, nil +} + +// LabelDeleteTemplate removes a design. +func (a *App) LabelDeleteTemplate(id int64) error { + if a.labelRepo == nil { + return fmt.Errorf("db not initialized") + } + return a.labelRepo.Delete(a.ctx, id) +} + +// LabelSetDefaultTemplate marks a design as the default for its kind. +func (a *App) LabelSetDefaultTemplate(id int64) error { + if a.labelRepo == nil { + return fmt.Errorf("db not initialized") + } + return a.labelRepo.SetDefault(a.ctx, id) +} + +// LabelSampleQSO is one row of preview data for the designer's QSO table. +type LabelSampleQSO struct { + Callsign string `json:"callsign"` + QSODate string `json:"qso_date"` // YYYY-MM-DD + TimeOn string `json:"time_on"` // HH:MM + Band string `json:"band"` + FreqMHz string `json:"freq"` + Mode string `json:"mode"` + RSTSent string `json:"rst_sent"` + RSTRcvd string `json:"rst_rcvd"` + Name string `json:"name"` + QTH string `json:"qth"` + Country string `json:"country"` +} + +// LabelSampleQSOs returns the last few real contacts for the designer's live +// preview — real data shows a too-narrow column immediately ("14074.0" does not +// fit where "7.1" did). Falls back to plausible fakes on an empty log; the +// preview must never be blank. +func (a *App) LabelSampleQSOs(limit int) []LabelSampleQSO { + if limit <= 0 || limit > 20 { + limit = 4 + } + fake := []LabelSampleQSO{ + {Callsign: "DL1ABC", QSODate: "2026-08-01", TimeOn: "14:32", Band: "20m", FreqMHz: "14.074", Mode: "FT8", RSTSent: "-08", RSTRcvd: "-12", Name: "Hans", QTH: "Berlin", Country: "Germany"}, + {Callsign: "VK3XYZ", QSODate: "2026-08-02", TimeOn: "09:15", Band: "15m", FreqMHz: "21.245", Mode: "SSB", RSTSent: "59", RSTRcvd: "57", Name: "Bruce", QTH: "Melbourne", Country: "Australia"}, + {Callsign: "JA1TOK", QSODate: "2026-08-03", TimeOn: "21:47", Band: "40m", FreqMHz: "7.012", Mode: "CW", RSTSent: "599", RSTRcvd: "579", Name: "Ken", QTH: "Tokyo", Country: "Japan"}, + {Callsign: "W1AW", QSODate: "2026-08-04", TimeOn: "18:03", Band: "10m", FreqMHz: "28.480", Mode: "SSB", RSTSent: "59", RSTRcvd: "59", Name: "Hiram", QTH: "Newington", Country: "United States"}, + } + if a.qso == nil { + return fake[:min(limit, len(fake))] + } + rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: limit}) + if err != nil || len(rows) == 0 { + return fake[:min(limit, len(fake))] + } + out := make([]LabelSampleQSO, 0, len(rows)) + for _, q := range rows { + s := LabelSampleQSO{ + Callsign: q.Callsign, + QSODate: q.QSODate.UTC().Format("2006-01-02"), + TimeOn: q.QSODate.UTC().Format("15:04"), + Band: q.Band, + Mode: q.Mode, + RSTSent: q.RSTSent, + RSTRcvd: q.RSTRcvd, + Name: q.Name, + QTH: q.QTH, + Country: q.Country, + } + if q.FreqHz != nil && *q.FreqHz > 0 { + s.FreqMHz = fmt.Sprintf("%.3f", float64(*q.FreqHz)/1e6) + } + out = append(out, s) + } + return out +} diff --git a/changelog.json b/changelog.json index c516bd9..8ed094a 100644 --- a/changelog.json +++ b/changelog.json @@ -16,7 +16,8 @@ "E-mail: a refused SMTP login now says what to do about it — Microsoft 365 and outlook.com have switched off password-based SMTP, and an app password does not bring it back.", "TCI panorama spots: the colour was sent as a negative number and ExpertSDR dropped every spot in silence. It now goes out as the unsigned ARGB integer the protocol document uses, and the first few spots are written to the log verbatim.", "Cluster: “Group duplicates” was hiding the same station on OTHER bands and modes — a DXpedition spotted on five bands showed as one line and four slots disappeared. A duplicate is now what it should always have been: the same station on the same band and mode.", - "LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station." + "LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.", + "Label Designer (Tools): design the labels for paper QSL work — a QSO label for the card (repeating QSO table, several contacts of the same station per label) and address labels for the envelope. Label sizes are profiles in millimetres with margins, seeded with the common Brother DK rolls; elements are dragged in place on a millimetre-true preview fed with your latest contacts. Printing to PDF comes next." ], "fr": [ "Téléchargement LoTW : les détails QSL (date du QSL, locator, état, comté) deviennent optionnels et désactivés par défaut — LoTW met environ dix fois plus longtemps à construire ce rapport, vingt minutes contre deux sur le même compte, et marquer une confirmation n'en a pas besoin. Toujours demandés automatiquement quand on ajoute les QSO absents du log.", @@ -32,7 +33,8 @@ "E-mail : un refus d'authentification SMTP explique désormais quoi faire — Microsoft 365 et outlook.com ont désactivé le SMTP par mot de passe, et un mot de passe d'application ne le rétablit pas.", "Spots sur le panorama TCI : la couleur partait en nombre négatif et ExpertSDR écartait chaque spot en silence. Elle est désormais envoyée en entier ARGB non signé, comme dans la documentation du protocole, et les premiers spots sont écrits tels quels dans le journal.", "Cluster : « Grouper les doublons » masquait la même station sur les AUTRES bandes et modes — une expédition spottée sur cinq bandes n'affichait qu'une ligne et quatre créneaux disparaissaient. Un doublon est désormais ce qu'il aurait toujours dû être : la même station sur la même bande et le même mode.", - "LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement." + "LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.", + "Créateur d'étiquettes (Outils) : dessinez les étiquettes de vos QSL papier — étiquette QSO pour la carte (tableau de QSO répétable, plusieurs contacts de la même station par étiquette) et étiquettes adresse pour l'enveloppe. Les formats sont des profils en millimètres avec marges, préremplis avec les rouleaux Brother DK courants ; les éléments se placent à la souris sur un aperçu fidèle au millimètre nourri de vos derniers contacts. L'impression en PDF viendra ensuite." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ca9867..2e12943 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -70,6 +70,7 @@ import { import { APP_VERSION, APP_AUTHOR } from '@/version'; import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; +import { LabelDesignerModal } from '@/components/labels/LabelDesignerModal'; import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; @@ -1267,6 +1268,7 @@ export default function App() { setQslPaperReq((r) => ({ call: c, n: (r?.n ?? 0) + 1 })); } const [qslDesignerOpen, setQslDesignerOpen] = useState(false); + const [labelDesignerOpen, setLabelDesignerOpen] = useState(false); const [eqslQsoId, setEqslQsoId] = useState(null); // QSO being sent as eQSL function closeQslTab() { setQslTabOpen(false); @@ -4998,6 +5000,7 @@ export default function App() { { type: 'item', label: t('dec.tab'), action: 'tools.decodes' }, { type: 'item', label: t('gsm.title'), action: 'tools.grids' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, + { type: 'item', label: t('tools.labelDesigner'), action: 'tools.labeldesigner' }, { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' }, @@ -5051,6 +5054,7 @@ export default function App() { case 'tools.decodes': openDecodesTab(); break; case 'tools.grids': openGridsTab(); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; + case 'tools.labeldesigner': setLabelDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.cwdecoder': toggleCwDecoder(); break; @@ -8712,6 +8716,7 @@ export default function App() { onError={(msg) => showToast(msg)} /> setQslDesignerOpen(false)} /> + setLabelDesignerOpen(false)} /> 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([]); + const [saved, setSaved] = useState([]); + const [samples, setSamples] = useState([]); + const [myVars, setMyVars] = useState>({}); + const [error, setError] = useState(''); + + // The design being edited (null = nothing open yet). + const [tplId, setTplId] = useState(0); + const [tpl, setTpl] = useState(null); + const [tplName, setTplName] = useState(''); + const [forProfile, setForProfile] = useState(true); + const [sel, setSel] = useState(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(null); + + const canvasRef = useRef(null); + const boxesRef = useRef([]); + 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 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; + }, [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) { + 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') => ( +
+ + set(parseFloat(ev.target.value) || 0)} /> +
+ ); + + return ( +
+
+ {/* header */} +
+ + {t('lbl.title')} +
+ {tpl && ( + <> + { setTplName(ev.target.value); setDirty(true); }} + placeholder={t('lbl.namePh')} /> + + + + )} + +
+ {error &&
{error}
} + +
+ {/* left: saved templates + stocks */} +
+
+ + +
+
+ {saved.map((s) => ( +
void openTemplate(s)}> +
+ {s.kind === 'qso' ? : } + {s.name} + + +
+
+ {s.kind === 'qso' ? t('lbl.kindQso') : t('lbl.kindAddr')} · {s.updated_at} +
+
+ ))} + {saved.length === 0 && ( +
{t('lbl.empty')}
+ )} +
+ {/* stocks */} +
+ + {stockOpen && ( +
+ +
+ + {stockDraft?.id ? ( + + ) : null} +
+ {stockDraft && ( +
+ setStockDraft({ ...stockDraft, name: ev.target.value })} /> +
+ {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')} +
+
+ {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')} +
+ +
+ )} +
+ )} +
+
+ + {/* centre: preview */} +
+ {tpl && stock ? ( + <> +
+ {t('lbl.stock')}: + + {stock.w_mm}×{stock.h_mm} mm +
+ +
+ + + {tpl.kind === 'address' && ( + + )} + {tpl.kind === 'qso' && !tpl.elements.some((x) => x.type === 'qso_table') && ( + + )} + {sel != null && ( + + )} +
+ + ) : ( +
+ {t('lbl.intro')} +
+ )} +
+ + {/* right: properties of the selection */} + {tpl && ( +
+ {!e ? ( +
{t('lbl.selectHint')}
+ ) : ( + <> +
+ {t(`lbl.el_${e.type}` as any)} +
+
+ {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 }))} +
+ + {e.type === 'text' && ( + <> +
+ + patchEl(sel!, { text: ev.target.value })} /> +
+ patchEl(sel!, { text: `${e.text ?? ''}<${v}>` })} t={t} /> +
+ {numField('pt', e.size_pt ?? 9, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')} + + + +
+ + )} + + {e.type === 'line' && numField(t('lbl.thickness'), e.thickness_mm ?? 0.3, (v) => patchEl(sel!, { thickness_mm: v }), 0.1)} + + {e.type === 'addr_block' && ( + <> +
+ +