470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
"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>
|
|
);
|
|
}
|