qsl designer
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
// 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 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;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
callsign: 'Callsign',
|
||||
operator: 'Operator name',
|
||||
info_line: 'Info line',
|
||||
country: 'Country block',
|
||||
insert: 'Photo insert',
|
||||
};
|
||||
|
||||
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 }: Props) {
|
||||
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
|
||||
const box = template.qso_box;
|
||||
|
||||
return (
|
||||
<div className="flex w-72 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user