qsl designer

This commit is contained in:
2026-06-11 21:54:35 +02:00
parent 6150498a9e
commit 408b29896c
252 changed files with 13989 additions and 277 deletions
@@ -0,0 +1,137 @@
// 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 } from './qslTypes';
interface Props {
presets: QSLPresetInfo[];
preset: string;
params: StyleParams;
onChange: (preset: string, params: StyleParams) => void;
}
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-24 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="flex-1" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="w-8 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 });
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>
{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>
)}
{has('outline_color') && (
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
onChange={(v) => set({ outline_color: v })} />
)}
{has('outline_width') && (
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
onChange={(v) => set({ outline_width: v })} />
)}
{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 } })} />
)}
{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 },
})} />
)}
{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,
},
})} />
)}
</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>
);
}