189 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|