From f9c33c3fb9356163e390548880e589206097f6a0 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 31 Jul 2026 20:03:19 -0400 Subject: [PATCH] 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 --- src/collections/users/Awards.ts | 20 + src/components/admin/awards/MedalDesigner.tsx | 476 ++++++++++++++++++ .../admin/awards/RibbonDesigner.tsx | 371 ++++++++++++++ 3 files changed, 867 insertions(+) create mode 100644 src/components/admin/awards/MedalDesigner.tsx create mode 100644 src/components/admin/awards/RibbonDesigner.tsx diff --git a/src/collections/users/Awards.ts b/src/collections/users/Awards.ts index c01f2ef..fa9e0f2 100644 --- a/src/collections/users/Awards.ts +++ b/src/collections/users/Awards.ts @@ -53,5 +53,25 @@ export const Awards: CollectionConfig = { type: "number", 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", + }, + }, ], }; diff --git a/src/components/admin/awards/MedalDesigner.tsx b/src/components/admin/awards/MedalDesigner.tsx new file mode 100644 index 0000000..32cf2c8 --- /dev/null +++ b/src/components/admin/awards/MedalDesigner.tsx @@ -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 = { + circle: { label: "Circle" }, + star: { label: "Star" }, + cross: { label: "Cross" }, + shield: { label: "Shield" }, +}; + +const FINISH_INFO: Record = { + 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 ( + + + + + + + + + + {shape === "circle" && ( + + )} + + {shape === "star" && ( + { + 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" && ( + + )} + + {shape === "shield" && ( + + )} + + {engraving && ( + 10 ? 6 : 8} + fontFamily="serif" + fontWeight="bold" + letterSpacing={0.5} + > + {engraving.toUpperCase()} + + )} + + ); +} + +// ─── 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 ( + + {/* 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 ( + + ); + }); + })()} + + {/* Small connector tab between ribbon and medal */} + + + {/* Medal body */} + + + ); +} + +// ─── 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) => + onChange(stripes.map((s, i) => (i === idx ? { ...s, ...patch } : s))); + + return ( +
+
+ + Ribbon Stripes + + +
+ {stripes.map((stripe, idx) => ( +
+ updateStripe(idx, { color: e.target.value })} + className="size-7 rounded cursor-pointer bg-transparent border-0 p-0" + /> + 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" + /> +
+ + updateStripe(idx, { width: Number(e.target.value) })} + className="flex-1 h-1 accent-zinc-400" + /> + + {totalWeight > 0 ? Math.round((stripe.width / totalWeight) * 100) : 0}% + +
+
+ +
+ ))} +
+ ); +} + +// ─── Shape Selector ───────────────────────────────────────────────── + +function ShapeSelector({ + value, + onChange, +}: { + value: MedalShape; + onChange: (s: MedalShape) => void; +}) { + return ( +
+ + Medal Shape + +
+ {(Object.keys(SHAPE_INFO) as MedalShape[]).map((shape) => { + const info = SHAPE_INFO[shape]; + const active = value === shape; + return ( + + ); + })} +
+
+ ); +} + +// ─── Finish Selector ──────────────────────────────────────────────── + +function FinishSelector({ + value, + onChange, +}: { + value: MetalFinish; + onChange: (f: MetalFinish) => void; +}) { + return ( +
+ + Metal Finish + +
+ {(Object.keys(FINISH_INFO) as MetalFinish[]).map((finish) => { + const info = FINISH_INFO[finish]; + const active = value === finish; + return ( + + ); + })} +
+
+ ); +} + +// ─── Main Designer ────────────────────────────────────────────────── + +export default function MedalDesigner() { + const { value, setValue } = useField(); + + const initial: MedalDesign = + value && typeof value === "object" && Array.isArray((value as MedalDesign).ribbonStripes) + ? (value as MedalDesign) + : DEFAULT_MEDAL; + + const [design, setDesign] = useState(initial); + const lastSaved = useRef(""); + + 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 ( +
+ +

+ Configure the medal shape, metal finish, ribbon, and optional engraving. +

+ + + +
+ + + +
+ + Engraving + + 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" + /> + + Short text engraved on the medal face (max 20 chars) + +
+
+ +
+ +
+
+ ); +} diff --git a/src/components/admin/awards/RibbonDesigner.tsx b/src/components/admin/awards/RibbonDesigner.tsx new file mode 100644 index 0000000..c67a001 --- /dev/null +++ b/src/components/admin/awards/RibbonDesigner.tsx @@ -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 = { + 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 ( + + + {(() => { + let x = 0; + return displayStripes.map((stripe, i) => { + const w = (stripe.width / totalWeight) * viewWidth; + const el = ( + + ); + 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 ( + + {info.symbol} + + ); + })} + + ); +} + +// ─── 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) => + onChange(stripes.map((s, i) => (i === idx ? { ...s, ...patch } : s))); + + return ( +
+
+ + Stripes + + +
+ {stripes.map((stripe, idx) => ( +
+ updateStripe(idx, { color: e.target.value })} + className="size-7 rounded cursor-pointer bg-transparent border-0 p-0" + /> + 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" + /> +
+ + updateStripe(idx, { width: Number(e.target.value) })} + className="flex-1 h-1 accent-zinc-400" + /> + + {totalWeight > 0 ? Math.round((stripe.width / totalWeight) * 100) : 0}% + +
+
+ +
+ ))} +
+ ); +} + +// ─── 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) => + onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d))); + + return ( +
+ + Devices + +
+ {(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => { + const info = DEVICE_INFO[type]; + return ( + + ); + })} +
+ {devices.length > 0 && ( +
+ {devices.map((device) => { + const info = DEVICE_INFO[device.type]; + return ( +
+ {info.symbol} + {info.label} +
+ + + updateDevice(device.id, { position: Number(e.target.value) }) + } + className="flex-1 h-1 accent-zinc-400" + /> + + {device.position} + +
+
+ + + updateDevice(device.id, { positionY: Number(e.target.value) }) + } + className="flex-1 h-1 accent-zinc-400" + /> + + {device.positionY} + +
+ +
+ ); + })} +
+ )} +
+ ); +} + +// ─── Main Designer ────────────────────────────────────────────────── + +export default function RibbonDesigner() { + const { value, setValue } = useField(); + + const initial: RibbonDesign = + value && typeof value === "object" && Array.isArray((value as RibbonDesign).stripes) + ? (value as RibbonDesign) + : DEFAULT_RIBBON; + + const [design, setDesign] = useState(initial); + const lastSaved = useRef(""); + + 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 ( +
+ +

+ Configure the ribbon colors, stripes, and device attachments for this award. +

+ + + + + + + +
+ ); +}