1
0
Fork 0

feat: add award ribbon and medal designers

- Add RibbonDesigner admin component for visual ribbon design
- Add MedalDesigner admin component for visual medal design
- Add ribbonDesign and medalDesign JSON fields to Awards collection
- Conditionally show design fields based on award type
This commit is contained in:
Jason Fraley 2026-07-31 20:03:19 -04:00
parent f16b76a198
commit f9c33c3fb9
3 changed files with 867 additions and 0 deletions

View file

@ -53,5 +53,25 @@ export const Awards: CollectionConfig = {
type: "number", type: "number",
min: 1, min: 1,
}, },
{
name: "ribbonDesign",
type: "json",
admin: {
components: {
Field: "@/components/admin/awards/RibbonDesigner",
},
condition: (data) => data?.type === "ribbon",
},
},
{
name: "medalDesign",
type: "json",
admin: {
components: {
Field: "@/components/admin/awards/MedalDesigner",
},
condition: (data) => data?.type === "medal",
},
},
], ],
}; };

View file

@ -0,0 +1,476 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useField } from "@payloadcms/ui";
// ─── Types ──────────────────────────────────────────────────────────
type MedalShape = "circle" | "star" | "cross" | "shield";
type MetalFinish = "gold" | "silver" | "bronze" | "copper" | "darkSteel";
type Stripe = {
color: string;
width: number;
};
type MedalDesign = {
ribbonStripes: Stripe[];
shape: MedalShape;
finish: MetalFinish;
engraving: string;
};
// ─── Constants ──────────────────────────────────────────────────────
const DEFAULT_MEDAL: MedalDesign = {
ribbonStripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#ca8a04", width: 5 },
{ color: "#b91c1c", width: 30 },
{ color: "#ca8a04", width: 5 },
{ color: "#1e3a5f", width: 30 },
],
shape: "circle",
finish: "gold",
engraving: "",
};
const MILITARY_COLORS: { name: string; hex: string }[] = [
{ name: "Scarlet", hex: "#b91c1c" },
{ name: "Crimson", hex: "#991b1b" },
{ name: "Dark Blue", hex: "#1e3a5f" },
{ name: "Navy", hex: "#1e293b" },
{ name: "Air Force Blue", hex: "#5b8fa8" },
{ name: "Gold", hex: "#ca8a04" },
{ name: "Yellow", hex: "#eab308" },
{ name: "Forest Green", hex: "#166534" },
{ name: "White", hex: "#f5f5f4" },
{ name: "Silver / Gray", hex: "#9ca3af" },
{ name: "Black", hex: "#18181b" },
{ name: "Maroon", hex: "#581c87" },
{ name: "Purple", hex: "#7e22ce" },
{ name: "Brown", hex: "#78350f" },
{ name: "Tan / Khaki", hex: "#d4a056" },
];
const SHAPE_INFO: Record<MedalShape, { label: string }> = {
circle: { label: "Circle" },
star: { label: "Star" },
cross: { label: "Cross" },
shield: { label: "Shield" },
};
const FINISH_INFO: Record<MetalFinish, { label: string; colors: string[] }> = {
gold: {
label: "Gold",
colors: ["#fbbf24", "#ca8a04", "#92400e"],
},
silver: {
label: "Silver",
colors: ["#e5e7eb", "#9ca3af", "#6b7280"],
},
bronze: {
label: "Bronze",
colors: ["#d97706", "#92400e", "#78350f"],
},
copper: {
label: "Copper",
colors: ["#f97316", "#c2410c", "#7c2d12"],
},
darkSteel: {
label: "Dark Steel",
colors: ["#6b7280", "#4b5563", "#374151"],
},
};
const RIBBON_ASPECT = 3.667;
const RIBBON_WIDTH = 366;
// ─── SVG Medal Shapes ───────────────────────────────────────────────
function MedalBody({
shape,
finish,
engraving,
cx,
cy,
}: {
shape: MedalShape;
finish: MetalFinish;
engraving: string;
cx: number;
cy: number;
}) {
const finishColors = FINISH_INFO[finish].colors;
const gradId = `medal-grad-${finish}`;
const r = 40;
return (
<g>
<defs>
<radialGradient id={gradId} cx="40%" cy="35%">
<stop offset="0%" stopColor={finishColors[0]} />
<stop offset="60%" stopColor={finishColors[1]} />
<stop offset="100%" stopColor={finishColors[2]} />
</radialGradient>
</defs>
{shape === "circle" && (
<circle cx={cx} cy={cy} r={r} fill={`url(#${gradId})`} stroke={finishColors[2]} strokeWidth={1.5} />
)}
{shape === "star" && (
<polygon
points={Array.from({ length: 10 }, (_, i) => {
const angle = (Math.PI / 2) + (i * Math.PI) / 5;
const radius = i % 2 === 0 ? r : r * 0.45;
return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`;
}).join(" ")}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "cross" && (
<path
d={`M${cx - 12},${cy - r} L${cx + 12},${cy - r} L${cx + 12},${cy - 12}
L${cx + r},${cy - 12} L${cx + r},${cy + 12} L${cx + 12},${cy + 12}
L${cx + 12},${cy + r} L${cx - 12},${cy + r} L${cx - 12},${cy + 12}
L${cx - r},${cy + 12} L${cx - r},${cy - 12} L${cx - 12},${cy - 12}Z`}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "shield" && (
<path
d={`M${cx},${cy - r}
Q${cx + r},${cy - r * 0.6} ${cx + r * 0.9},${cy - r * 0.1}
L${cx},${cy + r}
L${cx - r * 0.9},${cy - r * 0.1}
Q${cx - r},${cy - r * 0.6} ${cx},${cy - r}Z`}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{engraving && (
<text
x={cx}
y={cy + 2}
textAnchor="middle"
dominantBaseline="central"
fill={finishColors[2]}
fontSize={engraving.length > 10 ? 6 : 8}
fontFamily="serif"
fontWeight="bold"
letterSpacing={0.5}
>
{engraving.toUpperCase()}
</text>
)}
</g>
);
}
// ─── Preview ────────────────────────────────────────────────────────
function MedalPreview({ design }: { design: MedalDesign }) {
const { ribbonStripes, shape, finish, engraving } = design;
const totalWeight = ribbonStripes.reduce((s, stripe) => s + stripe.width, 0);
const viewWidth = RIBBON_WIDTH;
const ribbonTop = 14;
const ribbonBottom = 72;
const convergeRatio = 0.35;
const medalCx = viewWidth / 2;
const medalCy = 120;
const ribbonViewWidth = RIBBON_WIDTH;
const offset = 0;
return (
<svg
viewBox={`0 0 ${viewWidth} 175`}
className="w-full h-auto rounded border border-zinc-700 bg-zinc-950"
style={{ maxHeight: 220 }}
>
{/* Ribbon as upside-down triangle (V-fold) */}
{(() => {
const topStartX = offset;
const bottomCenter = medalCx;
const topWidth = ribbonViewWidth;
const bottomWidth = topWidth * convergeRatio;
let topX = topStartX;
return ribbonStripes.map((stripe, i) => {
const stripeW = (stripe.width / totalWeight) * ribbonViewWidth;
const fracStart = (topX - topStartX) / topWidth;
const fracEnd = (topX + stripeW - topStartX) / topWidth;
const tlx = topX;
const trx = topX + stripeW;
const blx = bottomCenter - bottomWidth / 2 + fracStart * bottomWidth;
const brx = bottomCenter - bottomWidth / 2 + fracEnd * bottomWidth;
topX += stripeW;
return (
<polygon
key={i}
points={`${tlx},${ribbonTop} ${trx},${ribbonTop} ${brx},${ribbonBottom} ${blx},${ribbonBottom}`}
fill={stripe.color}
/>
);
});
})()}
{/* Small connector tab between ribbon and medal */}
<rect
x={medalCx - 4}
y={ribbonBottom}
width={8}
height={8}
rx={2}
fill="#9ca3af"
/>
{/* Medal body */}
<MedalBody shape={shape} finish={finish} engraving={engraving} cx={medalCx} cy={medalCy} />
</svg>
);
}
// ─── Stripe Editor (shared pattern) ─────────────────────────────────
function StripeEditor({
stripes,
onChange,
}: {
stripes: Stripe[];
onChange: (s: Stripe[]) => void;
}) {
const totalWeight = stripes.reduce((sum, s) => sum + s.width, 0);
const addStripe = () => onChange([...stripes, { color: "#f5f5f4", width: 10 }]);
const removeStripe = (idx: number) => onChange(stripes.filter((_, i) => i !== idx));
const updateStripe = (idx: number, patch: Partial<Stripe>) =>
onChange(stripes.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
Ribbon Stripes
</span>
<button
type="button"
onClick={addStripe}
className="text-[11px] px-2 py-0.5 rounded bg-emerald-950 border border-emerald-700 text-emerald-200 hover:bg-emerald-900 transition-colors cursor-pointer"
>
+ Add Stripe
</button>
</div>
{stripes.map((stripe, idx) => (
<div key={idx} className="flex items-center gap-2 bg-zinc-900 rounded px-2 py-1.5">
<input
type="color"
value={stripe.color}
onChange={(e) => updateStripe(idx, { color: e.target.value })}
className="size-7 rounded cursor-pointer bg-transparent border-0 p-0"
/>
<input
type="text"
value={stripe.color}
onChange={(e) => updateStripe(idx, { color: e.target.value })}
className="text-[11px] font-mono bg-zinc-800 border border-zinc-700 rounded px-2 py-1 w-20 text-zinc-300"
/>
<div className="flex items-center gap-1.5 flex-1">
<label className="text-[10px] text-zinc-500 shrink-0">%</label>
<input
type="range"
min={1}
max={100}
value={stripe.width}
onChange={(e) => updateStripe(idx, { width: Number(e.target.value) })}
className="flex-1 h-1 accent-zinc-400"
/>
<span className="text-[10px] text-zinc-500 font-mono w-8 text-right">
{totalWeight > 0 ? Math.round((stripe.width / totalWeight) * 100) : 0}%
</span>
</div>
<div
className="size-5 rounded shrink-0 border border-zinc-700"
style={{ backgroundColor: stripe.color }}
/>
<button
type="button"
onClick={() => removeStripe(idx)}
className="text-zinc-500 hover:text-red-400 text-sm cursor-pointer"
>
\u00D7
</button>
</div>
))}
</div>
);
}
// ─── Shape Selector ─────────────────────────────────────────────────
function ShapeSelector({
value,
onChange,
}: {
value: MedalShape;
onChange: (s: MedalShape) => void;
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
Medal Shape
</span>
<div className="flex flex-wrap gap-1.5">
{(Object.keys(SHAPE_INFO) as MedalShape[]).map((shape) => {
const info = SHAPE_INFO[shape];
const active = value === shape;
return (
<button
key={shape}
type="button"
onClick={() => onChange(shape)}
className={`text-[11px] px-3 py-1.5 rounded border transition-colors cursor-pointer ${
active
? "bg-amber-950 border-amber-700 text-amber-200"
: "bg-zinc-800 border-zinc-700 text-zinc-400 hover:bg-zinc-700"
}`}
>
{info.label}
</button>
);
})}
</div>
</div>
);
}
// ─── Finish Selector ────────────────────────────────────────────────
function FinishSelector({
value,
onChange,
}: {
value: MetalFinish;
onChange: (f: MetalFinish) => void;
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
Metal Finish
</span>
<div className="flex flex-wrap gap-1.5">
{(Object.keys(FINISH_INFO) as MetalFinish[]).map((finish) => {
const info = FINISH_INFO[finish];
const active = value === finish;
return (
<button
key={finish}
type="button"
onClick={() => onChange(finish)}
className={`flex items-center gap-2 text-[11px] px-3 py-1.5 rounded border transition-colors cursor-pointer ${
active
? "bg-amber-950 border-amber-700 text-amber-200"
: "bg-zinc-800 border-zinc-700 text-zinc-400 hover:bg-zinc-700"
}`}
>
<span
className="size-3 rounded-full shrink-0 border border-zinc-600"
style={{
background: `linear-gradient(135deg, ${info.colors[0]}, ${info.colors[2]})`,
}}
/>
{info.label}
</button>
);
})}
</div>
</div>
);
}
// ─── Main Designer ──────────────────────────────────────────────────
export default function MedalDesigner() {
const { value, setValue } = useField<MedalDesign>();
const initial: MedalDesign =
value && typeof value === "object" && Array.isArray((value as MedalDesign).ribbonStripes)
? (value as MedalDesign)
: DEFAULT_MEDAL;
const [design, setDesign] = useState<MedalDesign>(initial);
const lastSaved = useRef<string>("");
useEffect(() => {
const serialized = JSON.stringify(design);
if (serialized !== lastSaved.current) {
lastSaved.current = serialized;
setValue(design);
}
}, [design, setValue]);
const setStripes = useCallback(
(stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })),
[],
);
const setShape = useCallback(
(shape: MedalShape) => setDesign((d) => ({ ...d, shape })),
[],
);
const setFinish = useCallback(
(finish: MetalFinish) => setDesign((d) => ({ ...d, finish })),
[],
);
const setEngraving = useCallback(
(engraving: string) => setDesign((d) => ({ ...d, engraving })),
[],
);
return (
<div className="field-type json flex flex-col gap-3">
<label className="text-sm font-medium text-zinc-300">Medal Design</label>
<p className="text-xs text-zinc-500 -mt-1">
Configure the medal shape, metal finish, ribbon, and optional engraving.
</p>
<MedalPreview design={design} />
<div className="flex flex-col gap-3">
<ShapeSelector value={design.shape} onChange={setShape} />
<FinishSelector value={design.finish} onChange={setFinish} />
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
Engraving
</span>
<input
type="text"
value={design.engraving}
onChange={(e) => setEngraving(e.target.value)}
maxLength={20}
placeholder="e.g. VALOR"
className="text-sm bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-zinc-300 placeholder:text-zinc-600"
/>
<span className="text-[10px] text-zinc-600">
Short text engraved on the medal face (max 20 chars)
</span>
</div>
</div>
<div className="border-t border-zinc-800 pt-3 mt-1">
<StripeEditor stripes={design.ribbonStripes} onChange={setStripes} />
</div>
</div>
);
}

View file

@ -0,0 +1,371 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useField } from "@payloadcms/ui";
// ─── Types ──────────────────────────────────────────────────────────
type DeviceType = "star" | "oakLeaf" | "arrowUp" | "campaignClasp";
type Device = {
id: string;
type: DeviceType;
position: number;
positionY: number;
};
type Stripe = {
color: string;
width: number;
};
type RibbonDesign = {
stripes: Stripe[];
devices: Device[];
mirrored: boolean;
};
// ─── Constants ──────────────────────────────────────────────────────
const DEFAULT_RIBBON: RibbonDesign = {
stripes: [
{ color: "#b91c1c", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
};
const MILITARY_COLORS: { name: string; hex: string }[] = [
{ name: "Scarlet", hex: "#b91c1c" },
{ name: "Crimson", hex: "#991b1b" },
{ name: "Red", hex: "#dc2626" },
{ name: "Dark Blue", hex: "#1e3a5f" },
{ name: "Navy", hex: "#1e293b" },
{ name: "Air Force Blue", hex: "#5b8fa8" },
{ name: "Sky Blue", hex: "#38bdf8" },
{ name: "Gold", hex: "#ca8a04" },
{ name: "Yellow", hex: "#eab308" },
{ name: "Forest Green", hex: "#166534" },
{ name: "Green", hex: "#16a34a" },
{ name: "Olive", hex: "#4d7c0f" },
{ name: "Maroon", hex: "#581c87" },
{ name: "Purple", hex: "#7e22ce" },
{ name: "Orange", hex: "#ea580c" },
{ name: "Brown", hex: "#78350f" },
{ name: "Tan / Khaki", hex: "#d4a056" },
{ name: "White", hex: "#f5f5f4" },
{ name: "Silver / Gray", hex: "#9ca3af" },
{ name: "Black", hex: "#18181b" },
];
const DEVICE_INFO: Record<DeviceType, { label: string; symbol: string }> = {
star: { label: "Star", symbol: "\u2605" },
oakLeaf: { label: "Oak Leaf", symbol: "\u{1F333}" },
arrowUp: { label: "Arrow", symbol: "\u2191" },
campaignClasp: { label: "Clasp", symbol: "\u2015" },
};
const RIBBON_ASPECT = 3.667;
const RIBBON_WIDTH = 366;
const PREVIEW_HEIGHT = 80;
// ─── Preview ────────────────────────────────────────────────────────
function RibbonPreview({ design }: { design: RibbonDesign }) {
const { stripes, devices, mirrored } = design;
if (stripes.length === 0) return null;
const displayStripes = mirrored
? [...stripes, ...stripes.slice(0, -1).reverse()]
: stripes;
const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0);
const viewWidth = RIBBON_WIDTH;
const viewHeight = PREVIEW_HEIGHT;
return (
<svg
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
className="w-full h-auto rounded border border-zinc-700 bg-zinc-950"
style={{ maxHeight: 140 }}
>
<rect x={0} y={0} width={viewWidth} height={viewHeight} rx={2} fill="#18181b" />
{(() => {
let x = 0;
return displayStripes.map((stripe, i) => {
const w = (stripe.width / totalWeight) * viewWidth;
const el = (
<rect
key={i}
x={x}
y={0}
width={w}
height={viewHeight}
fill={stripe.color}
/>
);
x += w;
return el;
});
})()}
{devices.map((device) => {
const info = DEVICE_INFO[device.type];
const cx = (device.position / 100) * viewWidth;
const cy = (device.positionY / 100) * viewHeight;
const fontSize = Math.min(viewHeight * 0.45, 24);
return (
<text
key={device.id}
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fill="#facc15"
fontSize={fontSize}
fontFamily="serif"
style={{ pointerEvents: "none" }}
>
{info.symbol}
</text>
);
})}
</svg>
);
}
// ─── Stripe Editor ──────────────────────────────────────────────────
function StripeEditor({
stripes,
onChange,
}: {
stripes: Stripe[];
onChange: (s: Stripe[]) => void;
}) {
const totalWeight = stripes.reduce((sum, s) => sum + s.width, 0);
const addStripe = () => onChange([...stripes, { color: "#f5f5f4", width: 10 }]);
const removeStripe = (idx: number) => onChange(stripes.filter((_, i) => i !== idx));
const updateStripe = (idx: number, patch: Partial<Stripe>) =>
onChange(stripes.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
Stripes
</span>
<button
type="button"
onClick={addStripe}
className="text-[11px] px-2 py-0.5 rounded bg-emerald-950 border border-emerald-700 text-emerald-200 hover:bg-emerald-900 transition-colors cursor-pointer"
>
+ Add Stripe
</button>
</div>
{stripes.map((stripe, idx) => (
<div key={idx} className="flex items-center gap-2 bg-zinc-900 rounded px-2 py-1.5">
<input
type="color"
value={stripe.color}
onChange={(e) => updateStripe(idx, { color: e.target.value })}
className="size-7 rounded cursor-pointer bg-transparent border-0 p-0"
/>
<input
type="text"
value={stripe.color}
onChange={(e) => updateStripe(idx, { color: e.target.value })}
className="text-[11px] font-mono bg-zinc-800 border border-zinc-700 rounded px-2 py-1 w-20 text-zinc-300"
/>
<div className="flex items-center gap-1.5 flex-1">
<label className="text-[10px] text-zinc-500 shrink-0">%</label>
<input
type="range"
min={1}
max={100}
value={stripe.width}
onChange={(e) => updateStripe(idx, { width: Number(e.target.value) })}
className="flex-1 h-1 accent-zinc-400"
/>
<span className="text-[10px] text-zinc-500 font-mono w-8 text-right">
{totalWeight > 0 ? Math.round((stripe.width / totalWeight) * 100) : 0}%
</span>
</div>
<div
className="size-5 rounded shrink-0 border border-zinc-700"
style={{ backgroundColor: stripe.color }}
/>
<button
type="button"
onClick={() => removeStripe(idx)}
className="text-zinc-500 hover:text-red-400 text-sm cursor-pointer"
>
\u00D7
</button>
</div>
))}
</div>
);
}
// ─── Device Editor ──────────────────────────────────────────────────
function DeviceEditor({
devices,
onChange,
}: {
devices: Device[];
onChange: (d: Device[]) => void;
}) {
const addDevice = (type: DeviceType) =>
onChange([
...devices,
{ id: crypto.randomUUID(), type, position: 50, positionY: 50 },
]);
const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id));
const updateDevice = (id: string, patch: Partial<Device>) =>
onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d)));
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
Devices
</span>
<div className="flex flex-wrap gap-1.5">
{(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => {
const info = DEVICE_INFO[type];
return (
<button
key={type}
type="button"
onClick={() => addDevice(type)}
className="text-[11px] px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-zinc-300 hover:bg-zinc-700 transition-colors cursor-pointer"
>
{info.symbol} {info.label}
</button>
);
})}
</div>
{devices.length > 0 && (
<div className="flex flex-col gap-1.5">
{devices.map((device) => {
const info = DEVICE_INFO[device.type];
return (
<div
key={device.id}
className="flex items-center gap-2 bg-zinc-900 rounded px-2 py-1.5"
>
<span className="text-sm">{info.symbol}</span>
<span className="text-[11px] text-zinc-400">{info.label}</span>
<div className="flex items-center gap-1.5 flex-1">
<label className="text-[10px] text-zinc-500 shrink-0">X</label>
<input
type="range"
min={0}
max={100}
value={device.position}
onChange={(e) =>
updateDevice(device.id, { position: Number(e.target.value) })
}
className="flex-1 h-1 accent-zinc-400"
/>
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
{device.position}
</span>
</div>
<div className="flex items-center gap-1.5 flex-1">
<label className="text-[10px] text-zinc-500 shrink-0">Y</label>
<input
type="range"
min={0}
max={100}
value={device.positionY}
onChange={(e) =>
updateDevice(device.id, { positionY: Number(e.target.value) })
}
className="flex-1 h-1 accent-zinc-400"
/>
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
{device.positionY}
</span>
</div>
<button
type="button"
onClick={() => removeDevice(device.id)}
className="text-zinc-500 hover:text-red-400 text-sm cursor-pointer"
>
\u00D7
</button>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── Main Designer ──────────────────────────────────────────────────
export default function RibbonDesigner() {
const { value, setValue } = useField<RibbonDesign>();
const initial: RibbonDesign =
value && typeof value === "object" && Array.isArray((value as RibbonDesign).stripes)
? (value as RibbonDesign)
: DEFAULT_RIBBON;
const [design, setDesign] = useState<RibbonDesign>(initial);
const lastSaved = useRef<string>("");
useEffect(() => {
const serialized = JSON.stringify(design);
if (serialized !== lastSaved.current) {
lastSaved.current = serialized;
setValue(design);
}
}, [design, setValue]);
const setStripes = useCallback(
(stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })),
[],
);
const setDevices = useCallback(
(devices: Device[]) => setDesign((d) => ({ ...d, devices })),
[],
);
const toggleMirror = useCallback(
() => setDesign((d) => ({ ...d, mirrored: !d.mirrored })),
[],
);
return (
<div className="field-type json flex flex-col gap-3">
<label className="text-sm font-medium text-zinc-300">Ribbon Design</label>
<p className="text-xs text-zinc-500 -mt-1">
Configure the ribbon colors, stripes, and device attachments for this award.
</p>
<RibbonPreview design={design} />
<button
type="button"
onClick={toggleMirror}
className={`text-[11px] px-3 py-1.5 rounded border w-fit transition-colors cursor-pointer ${
design.mirrored
? "bg-amber-950 border-amber-700 text-amber-200"
: "bg-zinc-800 border-zinc-700 text-zinc-400 hover:bg-zinc-700"
}`}
>
{design.mirrored ? "\u21C4 Mirrored" : "\u2194 Mirror Stripes"}
</button>
<StripeEditor stripes={design.stripes} onChange={setStripes} />
<DeviceEditor devices={design.devices} onChange={setDevices} />
</div>
);
}