Files
OpsLog/frontend/src/components/qsl/EditorPanel.tsx
T
2026-06-13 01:34:45 +02:00

189 lines
8.2 KiB
TypeScript

// EditorPanel — the designer's right-hand controls: card-level options
// (scrim, name, default) and per-selection editing (font, size, rotation,
// style preset + params; insert width/border; QSO box geometry).
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker';
interface Props {
template: CardTemplate;
sel: CardSelection;
presets: QSLPresetInfo[];
fontFamilies: string[]; // embedded + detected system faces
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
onPatchBox: (patch: Partial<QSOBox>) => void;
onScrim: (enabled: boolean) => void;
onSelect?: (sel: CardSelection) => void;
}
const TYPE_LABELS: Record<string, string> = {
callsign: 'Callsign',
operator: 'Operator name',
info_line: 'Info line',
country: 'Country + flag',
insert: 'Photo insert',
};
// A readable layer name, disambiguating the two info lines and numbering inserts.
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
if (el.type === 'info_line') {
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
}
if (el.type === 'insert') {
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
return `Photo insert ${n}`;
}
return TYPE_LABELS[el.type] ?? el.type;
}
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
onPatch: (idx: number, patch: Partial<CardElement>) => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={e.font} onValueChange={(font) => onPatch(idx, { font })}>
<SelectTrigger className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fontFamilies.map((f) => (
<SelectItem key={f} value={f}>{f === 'system-bold-sans' ? 'System bold' : f}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<NumberField label="Size" value={e.size ?? 0} min={8} max={500}
onChange={(size) => onPatch(idx, { size })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-45} max={45}
onChange={(rotate) => onPatch(idx, { rotate })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Text</Label>
<Input className="h-7 w-44 font-mono text-xs" value={e.text ?? ''}
onChange={(ev) => onPatch(idx, { text: ev.target.value })} />
</div>
<Separator className="my-2" />
<StylePresetPicker
presets={presets}
preset={e.style_preset ?? 'flat_modern'}
params={e.style_params ?? {}}
onChange={(style_preset, style_params: StyleParams) =>
onPatch(idx, { style_preset, style_params })}
/>
</div>
);
}
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
const box = template.qso_box;
return (
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={!!template.hero.scrim?.enabled}
onCheckedChange={(v) => onScrim(v === true)}
/>
Darken photo behind text (scrim)
</label>
</div>
{/* Layers: show/hide each piece, click a name to edit it. */}
<div className="space-y-1 rounded-md border border-border p-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
{template.elements.map((el, i) => (
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
{layerLabel(el, i, template.elements)}
</button>
</div>
))}
{box && (
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
QSO info box
</button>
</div>
)}
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
</div>
{!e && sel !== 'box' && (
<p className="text-xs text-muted-foreground">
Click an element on the card to edit it; drag to move it.
</p>
)}
{e && (e.type === 'callsign' || e.type === 'operator' || e.type === 'info_line') && (
<TextControls e={e} idx={sel as number} presets={presets}
fontFamilies={fontFamilies} onPatch={onPatchElement} />
)}
{e && e.type === 'insert' && (
<div className="space-y-2">
<NumberField label="Width" value={e.w ?? 0} min={80} max={1200}
onChange={(w) => onPatchElement(sel as number, { w })} />
<NumberField label="Border" value={e.border_px ?? 0} min={0} max={40}
onChange={(border_px) => onPatchElement(sel as number, { border_px })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-15} max={15}
onChange={(rotate) => onPatchElement(sel as number, { rotate })} />
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={!!e.shadow}
onCheckedChange={(v) => onPatchElement(sel as number, { shadow: v === true })} />
Drop shadow
</label>
</div>
)}
{e && e.type === 'country' && (
<NumberField label="Size" value={e.size ?? 30} min={16} max={80}
onChange={(size) => onPatchElement(sel as number, { size })} />
)}
{sel === 'box' && box && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={box.enabled}
onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
Show QSO box
</label>
<NumberField label="Width" value={box.w} min={300} max={1400}
onChange={(w) => onPatchBox({ w })} />
<NumberField label="Height" value={box.h} min={120} max={500}
onChange={(h) => onPatchBox({ h })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
onChange={(ev) => onPatchBox({ footer: ev.target.value })} />
</div>
</div>
)}
</div>
<p className="px-1 text-[11px] leading-snug text-muted-foreground">
Text supports {'{profile.*}'} and {'{qso.*}'} placeholders they fill in
automatically on every card.
</p>
</div>
);
}