up seafile

This commit is contained in:
2026-06-13 10:14:23 +02:00
parent 3cb2e466d8
commit ff53831be4
8 changed files with 230 additions and 24 deletions
+12 -1
View File
@@ -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) applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return 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) 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) return fmt.Errorf("eQSL sent but status not saved: %w", err)
} }
+89 -14
View File
@@ -6,11 +6,12 @@
// back to front: halo, drop shadow, outline, bevel base, bevel light edge, // back to front: halo, drop shadow, outline, bevel base, bevel light edge,
// gradient face, wavy gloss band clipped to the glyphs. // 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 type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
import { QSO_FIELD_LABELS } from './qslTypes'; import { QSO_FIELD_LABELS } from './qslTypes';
import type { CardAssets } from './qslAssets'; import type { CardAssets } from './qslAssets';
import { FONT_WEIGHTS } 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. // An editor selection: an element index or the QSO box.
export type CardSelection = number | 'box' | null; 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' }; 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 { function elementTransform(e: CardElement): string {
const r = e.rotate ? ` rotate(${e.rotate})` : ''; const r = e.rotate ? ` rotate(${e.rotate})` : '';
return `translate(${e.x} ${e.y})${r}`; 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<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null); const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
const measureCanvas = useRef<HTMLCanvasElement | null>(null); const measureCanvas = useRef<HTMLCanvasElement | null>(null);
// Measure the selected group for the dashed selection outline. For text we // Embedded fonts may still be loading when the card first mounts; the canvas
// use the glyphs' real ink metrics — getBBox would span the font's full // FX needs them for correct metrics, so (re)render once they're ready.
// ascent/descent, leaving a tall dead band above the letters and a frame const [fontsReady, setFontsReady] = useState(false);
// that doesn't hug the call. 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<number, TextFxResult>();
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(() => { useEffect(() => {
if (selected === null || selected === undefined) { setSelBox(null); return; } if (selected === null || selected === undefined) { setSelBox(null); return; }
const key = String(selected); const key = String(selected);
@@ -306,7 +374,8 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
if (!g) { setSelBox(null); return; } if (!g) { setSelBox(null); return; }
const transform = g.getAttribute('transform') ?? ''; const transform = g.getAttribute('transform') ?? '';
const el = typeof selected === 'number' ? t.elements[selected] : undefined; 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 cv = (measureCanvas.current ??= document.createElement('canvas'));
const ctx = cv.getContext('2d'); const ctx = cv.getContext('2d');
if (ctx) { if (ctx) {
@@ -325,7 +394,7 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
} }
const b = g.getBBox(); const b = g.getBBox();
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height }); 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<CardSelection, null>, ox: number, oy: number) => const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
(ev: React.PointerEvent<SVGGElement>) => { (ev: React.PointerEvent<SVGGElement>) => {
@@ -435,19 +504,25 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
</g> </g>
); );
} }
// Text elements. The inner translate pulls the glyphs up by the em-box // Text elements. Glossy/western calls render through the ported canvas
// top→cap-height gap so the element's y means "top of the visible // FX as an <image> (its trimmed bounds already give a tight frame, so
// letters" — without it the call sits ~0.2em below its box and can't // no CAP_INSET shim). Everything else is plain SVG text; the inner
// be pushed to the card's top edge. // 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 ( return (
<g <g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)} key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)} onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }} style={{ cursor: onMove ? 'move' : undefined }}
> >
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}> {fx ? (
<TextStack e={e} idx={idx} content={e.text ?? ''} /> <image href={fx.url} x={0} y={0} width={fx.w} height={fx.h} />
</g> ) : (
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
<TextStack e={e} idx={idx} content={e.text ?? ''} />
</g>
)}
</g> </g>
); );
})} })}
@@ -60,6 +60,9 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg'); const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(qsoId, templateId, jpeg); await SendEQSL(qsoId, templateId, jpeg);
setSent(true); 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) { } catch (e) {
setError(String(e)); setError(String(e));
} finally { } finally {
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import type { QSLPresetInfo, StyleParams } from './qslTypes'; import type { QSLPresetInfo, StyleParams, FxParams } from './qslTypes';
interface Props { interface Props {
presets: QSLPresetInfo[]; presets: QSLPresetInfo[];
@@ -48,6 +48,13 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
const has = (k: string) => info?.params.includes(k) ?? false; const has = (k: string) => info?.params.includes(k) ?? false;
const set = (patch: Partial<StyleParams>) => onChange(preset, { ...params, ...patch }); const set = (patch: Partial<StyleParams>) => 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<FxParams>) => set({ fx: { ...fx, ...patch } });
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Select <Select
@@ -90,25 +97,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
</div> </div>
</div> </div>
)} )}
{has('outline_color') && ( {!isFx && has('outline_color') && (
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'} <ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
onChange={(v) => set({ outline_color: v })} /> onChange={(v) => set({ outline_color: v })} />
)} )}
{has('outline_width') && ( {!isFx && has('outline_width') && (
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1} <SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
onChange={(v) => set({ outline_width: v })} /> onChange={(v) => set({ outline_width: v })} />
)} )}
{has('shine') && ( {!isFx && has('shine') && (
<SliderRow label="Gloss" value={params.shine?.coverage ?? 0.5} min={0.2} max={0.8} step={0.05} <SliderRow label="Gloss" value={params.shine?.coverage ?? 0.5} min={0.2} max={0.8} step={0.05}
onChange={(v) => set({ shine: { coverage: v, opacity: params.shine?.opacity ?? 0.95 } })} /> onChange={(v) => set({ shine: { coverage: v, opacity: params.shine?.opacity ?? 0.95 } })} />
)} )}
{has('halo') && ( {!isFx && has('halo') && (
<SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05} <SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05}
onChange={(v) => set({ onChange={(v) => set({
halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v }, halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v },
})} /> })} />
)} )}
{has('shadow') && ( {!isFx && has('shadow') && (
<SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05} <SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05}
onChange={(v) => set({ onChange={(v) => set({
shadow: { shadow: {
@@ -117,6 +124,35 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
}, },
})} /> })} />
)} )}
{isFx && !fxWestern && (
<>
<SliderRow label="Plump" value={fx.plump ?? 0.03} min={0} max={0.06} step={0.005}
onChange={(v) => setFx({ plump: v })} />
<SliderRow label="Gloss" value={fx.gloss ?? 0.85} min={0} max={1} step={0.05}
onChange={(v) => setFx({ gloss: v })} />
<SliderRow label="Gloss height" value={fx.gloss_h ?? 0.45} min={0.2} max={0.8} step={0.05}
onChange={(v) => setFx({ gloss_h: v })} />
<SliderRow label="Inner shadow" value={fx.inner_b ?? 0.7} min={0} max={1} step={0.05}
onChange={(v) => setFx({ inner_b: v })} />
<SliderRow label="Dark edge" value={fx.edge ?? 0.5} min={0} max={1} step={0.05}
onChange={(v) => setFx({ edge: v })} />
<SliderRow label="Silver rim" value={fx.outerw ?? 0.45} min={0} max={1} step={0.05}
onChange={(v) => setFx({ outerw: v })} />
</>
)}
{isFx && fxWestern && (
<>
<SliderRow label="3D depth" value={fx.depth ?? 12} min={0} max={40} step={1}
onChange={(v) => setFx({ depth: v })} />
<SliderRow label="Slant" value={fx.slant ?? 0.08} min={-0.4} max={0.4} step={0.02}
onChange={(v) => setFx({ slant: v })} />
<SliderRow label="Grunge" value={fx.grunge ?? 0.8} min={0} max={1} step={0.05}
onChange={(v) => setFx({ grunge: v })} />
<SliderRow label="Top light" value={fx.bevel ?? 0.45} min={0} max={1} step={0.05}
onChange={(v) => setFx({ bevel: v })} />
</>
)}
</div> </div>
); );
} }
+19
View File
@@ -54,6 +54,24 @@ export interface BevelFx {
light: string; light: string;
} }
// FxParams — per-call tuning of the canvas renderer (textFx.ts). All optional;
// omitted values fall back to the renderer's per-preset default.
export interface FxParams {
plump?: number;
edge?: number;
outerw?: number;
gloss?: number;
gloss_h?: number;
gloss_i?: number;
inner_b?: number;
depth?: number;
angle?: number;
slant?: number;
grunge?: number;
bevel?: number;
seed?: number;
}
export interface StyleParams { export interface StyleParams {
gradient?: string[]; gradient?: string[];
shine?: Shine; shine?: Shine;
@@ -63,6 +81,7 @@ export interface StyleParams {
shadow?: ShadowFx; shadow?: ShadowFx;
bevel_offset?: BevelFx; bevel_offset?: BevelFx;
color?: string; color?: string;
fx?: FxParams;
} }
export type ElementType = 'callsign' | 'operator' | 'info_line' | 'country' | 'insert'; export type ElementType = 'callsign' | 'operator' | 'info_line' | 'country' | 'insert';
+38
View File
@@ -1738,6 +1738,42 @@ export namespace qslcard {
this.light = source["light"]; this.light = source["light"];
} }
} }
export class FxParams {
plump?: number;
edge?: number;
outerw?: number;
gloss?: number;
gloss_h?: number;
gloss_i?: number;
inner_b?: number;
depth?: number;
angle?: number;
slant?: number;
grunge?: number;
bevel?: number;
seed?: number;
static createFrom(source: any = {}) {
return new FxParams(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.plump = source["plump"];
this.edge = source["edge"];
this.outerw = source["outerw"];
this.gloss = source["gloss"];
this.gloss_h = source["gloss_h"];
this.gloss_i = source["gloss_i"];
this.inner_b = source["inner_b"];
this.depth = source["depth"];
this.angle = source["angle"];
this.slant = source["slant"];
this.grunge = source["grunge"];
this.bevel = source["bevel"];
this.seed = source["seed"];
}
}
export class Halo { export class Halo {
color: string; color: string;
blur: number; blur: number;
@@ -1797,6 +1833,7 @@ export namespace qslcard {
shadow?: ShadowFx; shadow?: ShadowFx;
bevel_offset?: Bevel; bevel_offset?: Bevel;
color?: string; color?: string;
fx?: FxParams;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new StyleParams(source); return new StyleParams(source);
@@ -1812,6 +1849,7 @@ export namespace qslcard {
this.shadow = this.convertValues(source["shadow"], ShadowFx); this.shadow = this.convertValues(source["shadow"], ShadowFx);
this.bevel_offset = this.convertValues(source["bevel_offset"], Bevel); this.bevel_offset = this.convertValues(source["bevel_offset"], Bevel);
this.color = source["color"]; this.color = source["color"];
this.fx = this.convertValues(source["fx"], FxParams);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
+3 -3
View File
@@ -13,7 +13,7 @@ type Preset struct {
// gelParams are the knobs shared by the layered "gel" stacks. // gelParams are the knobs shared by the layered "gel" stacks.
var gelParams = map[string]bool{ var gelParams = map[string]bool{
"gradient": true, "shine": true, "outline_color": true, "outline_width": true, "gradient": true, "shine": true, "outline_color": true, "outline_width": true,
"halo": true, "shadow": true, "bevel_offset": true, "halo": true, "shadow": true, "bevel_offset": true, "fx": true,
} }
// Presets is the built-in style registry, keyed by preset name. // Presets is the built-in style registry, keyed by preset name.
@@ -23,8 +23,8 @@ var Presets = map[string]Preset{
Label: "Gel gold", Label: "Gel gold",
AllowedParams: gelParams, AllowedParams: gelParams,
Defaults: StyleParams{ Defaults: StyleParams{
Gradient: []string{"#FFE15A", "#FFC312", "#E07A00"}, Gradient: []string{"#FFE22D", "#FFD600", "#FFCC00"}, // bright XV9Q yellows; orange depth comes from the FX inner shadow
Shine: &Shine{Coverage: 0.52, Opacity: 0.95}, Shine: &Shine{Coverage: 0.6, Opacity: 1},
OutlineColor: "#2a3f5c", OutlineWidth: 10, OutlineColor: "#2a3f5c", OutlineWidth: 10,
Halo: &Halo{Color: "#cdd9e4", Blur: 6, Opacity: 0.4}, Halo: &Halo{Color: "#cdd9e4", Blur: 6, Opacity: 0.4},
Shadow: &ShadowFx{Dx: 6, Dy: 9, Blur: 5, Color: "#14243a", Opacity: 0.55}, Shadow: &ShadowFx{Dx: 6, Dy: 9, Blur: 5, Color: "#14243a", Opacity: 0.55},
+24
View File
@@ -85,6 +85,27 @@ type StyleParams struct {
Shadow *ShadowFx `json:"shadow,omitempty"` Shadow *ShadowFx `json:"shadow,omitempty"`
BevelOffset *Bevel `json:"bevel_offset,omitempty"` BevelOffset *Bevel `json:"bevel_offset,omitempty"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Fx *FxParams `json:"fx,omitempty"`
}
// FxParams tunes the canvas call renderer (glossy bubble / western 3D). All
// optional pointers — nil falls back to the renderer's per-preset default.
// Persisted in the template so per-call tweaks survive a round-trip; the values
// are consumed entirely by the frontend (textFx.ts).
type FxParams struct {
Plump *float64 `json:"plump,omitempty"`
Edge *float64 `json:"edge,omitempty"`
OuterW *float64 `json:"outerw,omitempty"`
Gloss *float64 `json:"gloss,omitempty"`
GlossH *float64 `json:"gloss_h,omitempty"`
GlossI *float64 `json:"gloss_i,omitempty"`
InnerB *float64 `json:"inner_b,omitempty"`
Depth *float64 `json:"depth,omitempty"`
Angle *float64 `json:"angle,omitempty"`
Slant *float64 `json:"slant,omitempty"`
Grunge *float64 `json:"grunge,omitempty"`
Bevel *float64 `json:"bevel,omitempty"`
Seed *float64 `json:"seed,omitempty"`
} }
// setKeys lists the JSON names of the params that are actually set, for // setKeys lists the JSON names of the params that are actually set, for
@@ -118,6 +139,9 @@ func (p *StyleParams) setKeys() []string {
if p.Color != "" { if p.Color != "" {
keys = append(keys, "color") keys = append(keys, "color")
} }
if p.Fx != nil {
keys = append(keys, "fx")
}
return keys return keys
} }