1
0
Fork 0
polaris-task-force/src/components/admin/awards/MedalDesigner.tsx

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>
);
}