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:
parent
f16b76a198
commit
f9c33c3fb9
3 changed files with 867 additions and 0 deletions
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
476
src/components/admin/awards/MedalDesigner.tsx
Normal file
476
src/components/admin/awards/MedalDesigner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
src/components/admin/awards/RibbonDesigner.tsx
Normal file
371
src/components/admin/awards/RibbonDesigner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue