212 lines
9.5 KiB
TypeScript
212 lines
9.5 KiB
TypeScript
// StylePresetPicker — preset choice + per-preset parameter controls for the
|
|
// selected text element. Only the knobs the preset declares (params) are
|
|
// shown, matching the backend whitelist so saving can't fail on a stray key.
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import type { QSLPresetInfo, StyleParams, FxParams } from './qslTypes';
|
|
|
|
interface Props {
|
|
presets: QSLPresetInfo[];
|
|
preset: string;
|
|
params: StyleParams;
|
|
onChange: (preset: string, params: StyleParams) => void;
|
|
}
|
|
|
|
// Quick colour palettes per FX family (mirrors the reference generators):
|
|
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
|
|
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
|
|
const GLOSSY_PALETTES: Palette[] = [
|
|
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
|
|
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
|
|
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
|
|
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
|
|
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
|
|
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
|
|
];
|
|
const WESTERN_PALETTES: Palette[] = [
|
|
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
|
|
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
|
|
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
|
|
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
|
|
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
|
|
];
|
|
|
|
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
<input
|
|
type="color"
|
|
className="h-7 w-10 cursor-pointer rounded border border-border bg-transparent"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SliderRow({ label, value, min, max, step, onChange }: {
|
|
label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
|
|
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
|
|
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
|
|
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StylePresetPicker({ presets, preset, params, onChange }: Props) {
|
|
const info = presets.find((p) => p.name === preset);
|
|
const has = (k: string) => info?.params.includes(k) ?? false;
|
|
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 (
|
|
<div className="space-y-2">
|
|
<Select
|
|
value={preset}
|
|
onValueChange={(name) => {
|
|
const next = presets.find((p) => p.name === name);
|
|
// Switching presets resets the params to that preset's defaults —
|
|
// stale keys from the previous preset would be rejected on save.
|
|
onChange(name, next ? { ...next.defaults } : {});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8">
|
|
<SelectValue placeholder="Style" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{presets.map((p) => (
|
|
<SelectItem key={p.name} value={p.name}>{p.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{isFx && (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-xs text-muted-foreground">Palette</Label>
|
|
<div className="flex flex-wrap gap-1">
|
|
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
|
|
<button
|
|
key={p.name} type="button" title={p.name}
|
|
className="h-6 w-6 rounded border border-border"
|
|
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
|
|
onClick={() => onChange(preset, {
|
|
...params,
|
|
gradient: [p.top, p.mid, p.bot],
|
|
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
|
|
})}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{has('color') && (
|
|
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
|
)}
|
|
{has('gradient') && (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-xs text-muted-foreground">Gradient</Label>
|
|
<div className="flex gap-1">
|
|
{(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400']).map((c, i) => (
|
|
<input
|
|
key={i} type="color" value={c}
|
|
className="h-7 w-8 cursor-pointer rounded border border-border bg-transparent"
|
|
onChange={(e) => {
|
|
const g = [...(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400'])];
|
|
g[i] = e.target.value;
|
|
set({ gradient: g });
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!isFx && has('outline_color') && (
|
|
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
|
|
onChange={(v) => set({ outline_color: v })} />
|
|
)}
|
|
{!isFx && has('outline_width') && (
|
|
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
|
|
onChange={(v) => set({ outline_width: v })} />
|
|
)}
|
|
{!isFx && has('shine') && (
|
|
<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 } })} />
|
|
)}
|
|
{!isFx && has('halo') && (
|
|
<SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05}
|
|
onChange={(v) => set({
|
|
halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v },
|
|
})} />
|
|
)}
|
|
{!isFx && has('shadow') && (
|
|
<SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05}
|
|
onChange={(v) => set({
|
|
shadow: {
|
|
dx: params.shadow?.dx ?? 5, dy: params.shadow?.dy ?? 8,
|
|
blur: params.shadow?.blur ?? 5, color: params.shadow?.color ?? '#14243a', opacity: v,
|
|
},
|
|
})} />
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// Tiny labelled numeric input shared by the designer's right panel.
|
|
export function NumberField({ label, value, onChange, min, max }: {
|
|
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
<Input
|
|
type="number" className="h-7 w-20 text-right" value={value} min={min} max={max}
|
|
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|