From ff53831be4477459f028888dfcf11c3cdac6a042 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sat, 13 Jun 2026 10:14:23 +0200 Subject: [PATCH] up seafile --- app_qsl_designer.go | 13 ++- frontend/src/components/qsl/CardPreview.tsx | 103 +++++++++++++++--- frontend/src/components/qsl/SendEQSLModal.tsx | 3 + .../src/components/qsl/StylePresetPicker.tsx | 48 +++++++- frontend/src/components/qsl/qslTypes.ts | 19 ++++ frontend/wailsjs/go/models.ts | 38 +++++++ internal/qslcard/presets.go | 6 +- internal/qslcard/template.go | 24 ++++ 8 files changed, 230 insertions(+), 24 deletions(-) diff --git a/app_qsl_designer.go b/app_qsl_designer.go index 706d7dc..bc995a0 100644 --- a/app_qsl_designer.go +++ b/app_qsl_designer.go @@ -425,7 +425,18 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error { applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err) return err } - if err := a.qso.MarkEQSLSent(a.ctx, qsoID, time.Now().UTC().Format("20060102")); err != nil { + // Stamp the standard ADIF eqsl_sent flag plus an app-specific timestamp of + // the eQSL-card e-mail (APP_OPSLOG_QSL_SENT) — distinct from eqsl_sent, which + // an eQSL.cc upload may also set. q came straight from GetByID, so a full + // Update rewrites the row unchanged apart from these fields. + now := time.Now().UTC() + q.EQSLSent = "Y" + q.EQSLSentDate = now.Format("20060102") + if q.Extras == nil { + q.Extras = map[string]string{} + } + q.Extras["APP_OPSLOG_QSL_SENT"] = now.Format(time.RFC3339) + if err := a.qso.Update(a.ctx, q); err != nil { applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err) return fmt.Errorf("eQSL sent but status not saved: %w", err) } diff --git a/frontend/src/components/qsl/CardPreview.tsx b/frontend/src/components/qsl/CardPreview.tsx index 4f31071..0444b46 100644 --- a/frontend/src/components/qsl/CardPreview.tsx +++ b/frontend/src/components/qsl/CardPreview.tsx @@ -6,11 +6,12 @@ // back to front: halo, drop shadow, outline, bevel base, bevel light edge, // gradient face, wavy gloss band clipped to the glyphs. -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes'; import { QSO_FIELD_LABELS } from './qslTypes'; import type { CardAssets } from './qslAssets'; import { FONT_WEIGHTS } from './qslAssets'; +import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx'; // An editor selection: an element index or the QSO box. export type CardSelection = number | 'box' | null; @@ -46,6 +47,49 @@ function fontSpec(font?: string): { family: string; weight: number | 'normal' } return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' }; } +// Presets whose call text is rendered with the ported canvas FX (the only way +// to get the exact glossy/western look). Everything else stays plain SVG text. +const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']); +const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']); +function fxKindFor(preset?: string): TextFxKind | null { + if (preset && FX_GLOSSY.has(preset)) return 'glossy'; + if (preset && FX_WESTERN.has(preset)) return 'western'; + return null; +} +function buildFxParams(e: CardElement): TextFxParams | null { + const kind = fxKindFor(e.style_preset); + if (!kind || !e.size) return null; + const fs = fontSpec(e.font); + const sp = e.style_params ?? {}; + const fx = sp.fx ?? {}; + const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']); + const silver = e.style_preset?.includes('silver'); + return { + kind, + text: e.text ?? '', + weight: String(fs.weight === 'normal' ? 400 : fs.weight), + family: fs.family, + size: e.size, + space: e.size * (kind === 'western' ? 0.08 : 0.04), + cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0], + // Dark inter-letter edge — fixed near-black (the navy outline adaptStyle + // sets is for the old SVG stack, not this look). + cDark: kind === 'western' ? '#1c130a' : '#262630', + cOuter: silver ? '#e8edf2' : '#ced3db', + // Per-call overrides from the editor (undefined → renderer default). + plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss, + glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b, + depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge, + bevel: fx.bevel, seed: fx.seed, + }; +} +// Stable signature of an element's FX-relevant fields, so dragging (x/y only) +// doesn't trigger an expensive re-render of the canvas text. +function fxSignature(e: CardElement): string { + if (!fxKindFor(e.style_preset)) return ''; + return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|'); +} + function elementTransform(e: CardElement): string { const r = e.rotate ? ` rotate(${e.rotate})` : ''; return `translate(${e.x} ${e.y})${r}`; @@ -295,10 +339,34 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove, const drag = useRef<{ sel: Exclude; sx: number; sy: number; ox: number; oy: number } | null>(null); const measureCanvas = useRef(null); - // Measure the selected group for the dashed selection outline. For text we - // use the glyphs' real ink metrics — getBBox would span the font's full - // ascent/descent, leaving a tall dead band above the letters and a frame - // that doesn't hug the call. + // Embedded fonts may still be loading when the card first mounts; the canvas + // FX needs them for correct metrics, so (re)render once they're ready. + const [fontsReady, setFontsReady] = useState(false); + useEffect(() => { + let alive = true; + const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve(); + void ready.then(() => { if (alive) setFontsReady(true); }); + return () => { alive = false; }; + }, [assets]); + + // Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields + // so dragging doesn't regenerate it. Maps element index → PNG + size. + const fxSig = t.elements.map(fxSignature).join(';'); + const fxImages = useMemo(() => { + const map = new Map(); + if (!fontsReady) return map; + t.elements.forEach((e, idx) => { + const params = buildFxParams(e); + if (!params) return; + try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ } + }); + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fxSig, fontsReady]); + + // Measure the selected group for the dashed selection outline. For plain SVG + // text we use the glyphs' real ink metrics — getBBox would span the font's + // full ascent/descent. FX call images fall through to getBBox (already tight). useEffect(() => { if (selected === null || selected === undefined) { setSelBox(null); return; } const key = String(selected); @@ -306,7 +374,8 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove, if (!g) { setSelBox(null); return; } const transform = g.getAttribute('transform') ?? ''; const el = typeof selected === 'number' ? t.elements[selected] : undefined; - if (el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) { + const isFx = el ? !!fxKindFor(el.style_preset) : false; + if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) { const cv = (measureCanvas.current ??= document.createElement('canvas')); const ctx = cv.getContext('2d'); if (ctx) { @@ -325,7 +394,7 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove, } const b = g.getBBox(); setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height }); - }, [selected, model, assets, width, t.elements]); + }, [selected, model, assets, width, t.elements, fxImages]); const startDrag = (sel: Exclude, ox: number, oy: number) => (ev: React.PointerEvent) => { @@ -435,19 +504,25 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove, ); } - // Text elements. The inner translate pulls the glyphs up by the em-box - // top→cap-height gap so the element's y means "top of the visible - // letters" — without it the call sits ~0.2em below its box and can't - // be pushed to the card's top edge. + // Text elements. Glossy/western calls render through the ported canvas + // FX as an (its trimmed bounds already give a tight frame, so + // no CAP_INSET shim). Everything else is plain SVG text; the inner + // translate pulls the glyphs up to the em-box cap line so the element's + // y means "top of the visible letters". + const fx = fxImages.get(idx); return ( - - - + {fx ? ( + + ) : ( + + + + )} ); })} diff --git a/frontend/src/components/qsl/SendEQSLModal.tsx b/frontend/src/components/qsl/SendEQSLModal.tsx index 193b9f6..57b9a50 100644 --- a/frontend/src/components/qsl/SendEQSLModal.tsx +++ b/frontend/src/components/qsl/SendEQSLModal.tsx @@ -60,6 +60,9 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) { const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg'); await SendEQSL(qsoId, templateId, jpeg); setSent(true); + // Auto-close shortly after the success tick (the grid refreshes on the + // qsl:sent event); the user doesn't need to dismiss it manually. + window.setTimeout(onClose, 1100); } catch (e) { setError(String(e)); } finally { diff --git a/frontend/src/components/qsl/StylePresetPicker.tsx b/frontend/src/components/qsl/StylePresetPicker.tsx index 09289a2..adbd285 100644 --- a/frontend/src/components/qsl/StylePresetPicker.tsx +++ b/frontend/src/components/qsl/StylePresetPicker.tsx @@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; -import type { QSLPresetInfo, StyleParams } from './qslTypes'; +import type { QSLPresetInfo, StyleParams, FxParams } from './qslTypes'; interface Props { presets: QSLPresetInfo[]; @@ -48,6 +48,13 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props) const has = (k: string) => info?.params.includes(k) ?? false; const set = (patch: Partial) => onChange(preset, { ...params, ...patch }); + // gel_* presets are rendered by the canvas FX, not the SVG stack: show the FX + // knobs instead of the old shine/halo/shadow/outline rows. + const isFx = preset.startsWith('gel_'); + const fxWestern = preset.includes('grunge'); + const fx: FxParams = params.fx ?? {}; + const setFx = (patch: Partial) => set({ fx: { ...fx, ...patch } }); + return (