From 73cae855ed7636f46dc127dccea69e9f007f1051 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 28 Aug 2026 21:35:50 +0200 Subject: [PATCH] chore(labels): park the label designer and printing on feature/labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 3 - app_labels.go | 208 ------- app_labels_print.go | 90 --- changelog.json | 8 +- frontend/src/App.tsx | 10 - .../components/labels/LabelDesignerModal.tsx | 555 ------------------ .../src/components/labels/LabelPrintModal.tsx | 502 ---------------- frontend/src/components/labels/labelRender.ts | 190 ------ frontend/src/components/labels/labelTypes.ts | 118 ---- frontend/src/lib/i18n.tsx | 88 --- frontend/wailsjs/go/main/App.d.ts | 23 - frontend/wailsjs/go/main/App.js | 44 -- frontend/wailsjs/go/models.ts | 107 ---- .../db/migrations/0031_label_templates.sql | 30 - internal/db/roles.go | 2 - internal/labels/labels.go | 194 ------ internal/labels/repo.go | 252 -------- internal/labels/stockjson.go | 13 - internal/pdf/pdf.go | 131 ----- internal/pdf/pdf_test.go | 54 -- internal/qso/qso.go | 13 +- 21 files changed, 4 insertions(+), 2631 deletions(-) delete mode 100644 app_labels.go delete mode 100644 app_labels_print.go delete mode 100644 frontend/src/components/labels/LabelDesignerModal.tsx delete mode 100644 frontend/src/components/labels/LabelPrintModal.tsx delete mode 100644 frontend/src/components/labels/labelRender.ts delete mode 100644 frontend/src/components/labels/labelTypes.ts delete mode 100644 internal/db/migrations/0031_label_templates.sql delete mode 100644 internal/labels/labels.go delete mode 100644 internal/labels/repo.go delete mode 100644 internal/labels/stockjson.go delete mode 100644 internal/pdf/pdf.go delete mode 100644 internal/pdf/pdf_test.go diff --git a/app.go b/app.go index cb48d5d..98db935 100644 --- a/app.go +++ b/app.go @@ -44,7 +44,6 @@ import ( "hamlog/internal/gridcache" "hamlog/internal/integrations/udp" "hamlog/internal/kpa" - "hamlog/internal/labels" "hamlog/internal/lookup" "hamlog/internal/lotwusers" "hamlog/internal/netctl" @@ -736,7 +735,6 @@ 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 @@ -1187,7 +1185,6 @@ 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 deleted file mode 100644 index e447818..0000000 --- a/app_labels.go +++ /dev/null @@ -1,208 +0,0 @@ -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/app_labels_print.go b/app_labels_print.go deleted file mode 100644 index 54895bb..0000000 --- a/app_labels_print.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -// The label PRINT path: pick the paper-QSL queue, review each address, choose -// the routing, and export PDFs whose pages are the exact label size — one PDF -// per label kind, because a roll printer holds one stock at a time and a file -// mixing 29 mm addresses with 62 mm QSO labels could not be printed at all. -// -// The pages arrive from the frontend already rasterised: the designer's canvas -// renderer draws them at the stock's dpi, so what was previewed is — pixel for -// pixel — what lands in the PDF. Go only carries them to disk. - -import ( - "encoding/base64" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "hamlog/internal/applog" - "hamlog/internal/pdf" - "hamlog/internal/qso" -) - -// LabelPaperQueue returns the contacts whose paper QSL is REQUESTED or QUEUED -// (ADIF qsl_sent R/Q) — the natural worklist for a labelling session. The -// frontend groups them by callsign. -func (a *App) LabelPaperQueue() ([]qso.QSO, error) { - if a.qso == nil { - return nil, fmt.Errorf("db not initialized") - } - return a.qso.List(a.ctx, qso.ListFilter{ - QSLSentIn: []string{"R", "Q"}, - Limit: 10_000, - }) -} - -// LabelPDFPage is one page of the session's output: a rasterised label and its -// physical size. Sizes vary WITHIN one document — the operator asked for a -// single PDF holding QSO labels, addresses and return labels together, and PDF -// pages each carry their own MediaBox, so a 90×29 page can follow a 100×62 one. -type LabelPDFPage struct { - PNG string `json:"png"` // base64, data-URL prefix tolerated - WMm float64 `json:"w_mm"` - HMm float64 `json:"h_mm"` -} - -// LabelOpenPDF writes the session's labels to ONE temporary PDF and opens it in -// the system viewer, from which the operator prints. No save dialog by choice: -// the file is a print run, not a document to keep — anyone who wants to keep it -// saves from the viewer. -func (a *App) LabelOpenPDF(pages []LabelPDFPage) (string, error) { - if len(pages) == 0 { - return "", fmt.Errorf("nothing to print") - } - var doc pdf.Doc - for i, pg := range pages { - if pg.WMm < 5 || pg.HMm < 5 || pg.WMm > 400 || pg.HMm > 400 { - return "", fmt.Errorf("page %d: label size out of range", i+1) - } - p := pg.PNG - if idx := strings.Index(p, ","); idx >= 0 && strings.Contains(p[:idx], "base64") { - p = p[idx+1:] - } - raw, err := base64.StdEncoding.DecodeString(p) - if err != nil { - return "", fmt.Errorf("page %d: %w", i+1, err) - } - if err := doc.AddImagePage(raw, pg.WMm, pg.HMm); err != nil { - return "", fmt.Errorf("page %d: %w", i+1, err) - } - } - out, err := doc.Bytes() - if err != nil { - return "", err - } - // A timestamped name in the temp dir: two sessions in one evening must not - // fight over the file, least of all while a viewer holds the first one open. - path := filepath.Join(os.TempDir(), fmt.Sprintf("opslog-labels-%s.pdf", time.Now().Format("20060102-150405"))) - if err := os.WriteFile(path, out, 0o644); err != nil { - return "", err - } - applog.Printf("labels: wrote %d page(s) to %s", len(pages), path) - if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start(); err != nil { - applog.Printf("labels: could not open the PDF viewer: %v", err) - return "", fmt.Errorf("the PDF was written to %s but no viewer opened: %w", path, err) - } - return path, nil -} diff --git a/changelog.json b/changelog.json index 3152a2a..d22cbf8 100644 --- a/changelog.json +++ b/changelog.json @@ -3,14 +3,10 @@ "version": "0.26.23", "date": "", "en": [ - "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.", - "Label printing (Tools → Print QSL labels): a three-step session — pick from the paper-QSL queue (sent status R/Q), check each address with a routing choice (direct / bureau / via manager) and a QRZ fetch (the MANAGER’s address when routing says via), then ONE PDF holding every label at its exact size, opened straight in the viewer to print from. Finishing marks the contacts sent with the date and the via." + "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." ], "fr": [ - "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.", - "Impression des étiquettes (Outils → Imprimer les étiquettes QSL) : une session en trois étapes — choisir dans la file QSL papier (statut envoyé R/Q), vérifier chaque adresse avec le routage (direct / bureau / via manager) et une récupération QRZ (l'adresse du MANAGER quand le routage le dit), puis UN PDF contenant toutes les étiquettes à leur taille exacte, ouvert directement dans le lecteur pour impression. La fin de session marque les contacts envoyés avec la date et le moyen." + "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." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 09037bd..6ca9867 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -70,8 +70,6 @@ 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 { LabelPrintModal } from '@/components/labels/LabelPrintModal'; import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; @@ -1269,8 +1267,6 @@ export default function App() { setQslPaperReq((r) => ({ call: c, n: (r?.n ?? 0) + 1 })); } const [qslDesignerOpen, setQslDesignerOpen] = useState(false); - const [labelDesignerOpen, setLabelDesignerOpen] = useState(false); - const [labelPrintOpen, setLabelPrintOpen] = useState(false); const [eqslQsoId, setEqslQsoId] = useState(null); // QSO being sent as eQSL function closeQslTab() { setQslTabOpen(false); @@ -5002,8 +4998,6 @@ 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: 'item', label: t('tools.labelPrint'), action: 'tools.labelprint' }, { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' }, @@ -5057,8 +5051,6 @@ 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.labelprint': setLabelPrintOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.cwdecoder': toggleCwDecoder(); break; @@ -8720,8 +8712,6 @@ export default function App() { onError={(msg) => showToast(msg)} /> setQslDesignerOpen(false)} /> - setLabelDesignerOpen(false)} /> - setLabelPrintOpen(false)} /> 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([]); - 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 paneRef = useRef(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([]); - 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; - // 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) { - 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' && ( - <> -
- -