'use client'; import { useEffect, useRef, useState } from 'react'; import { getColorName } from '@/lib/colorNames'; function frontFieldName(hex: string) { return `colorPhoto__${hex.replace('#', '')}`; } function backFieldName(hex: string) { return `colorPhotoBack__${hex.replace('#', '')}`; } function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new window.Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); } // Samples the photo on an offscreen canvas and averages the non-background // pixels — product photos are typically shot on a plain white/light backdrop, // so pixels that are both very light and low-saturation (the backdrop) are // excluded, leaving mostly garment pixels to average into one hex color. async function detectDominantColor(file: File): Promise { const objectUrl = URL.createObjectURL(file); try { const img = await loadImage(objectUrl); const size = 120; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) return '#808080'; ctx.drawImage(img, 0, 0, size, size); const { data } = ctx.getImageData(0, 0, size, size); let r = 0; let g = 0; let b = 0; let count = 0; for (let i = 0; i < data.length; i += 4) { const pr = data[i]; const pg = data[i + 1]; const pb = data[i + 2]; const pa = data[i + 3]; if (pa < 200) continue; const max = Math.max(pr, pg, pb); const min = Math.min(pr, pg, pb); const lightness = (max + min) / 2 / 255; const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255)); if (lightness > 0.92 && sat < 0.12) continue; // near-white/gray backdrop r += pr; g += pg; b += pb; count += 1; } if (count === 0) { r = data[0]; g = data[1]; b = data[2]; count = 1; } r = Math.round(r / count); g = Math.round(g / count); b = Math.round(b / count); return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`; } finally { URL.revokeObjectURL(objectUrl); } } function hexDistance(a: string, b: string) { const [ar, ag, ab] = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16)); const [br, bg, bb] = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16)); return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2); } // Snaps a freshly-detected color onto an existing one if they're close // (different lighting/JPEG noise shouldn't produce two near-identical // swatches for what's really the same shirt color). function findNearbyColor(hex: string, existing: string[], threshold = 30) { return existing.find((c) => hexDistance(c, hex) < threshold) ?? null; } type BulkResult = { filename: string; hex: string; variant: 'front' | 'back' }; export default function ColorPicker({ name, defaultColors = [], defaultColorPhotos = {}, defaultColorPhotosBack = {}, }: { name: string; defaultColors?: string[]; defaultColorPhotos?: Record; defaultColorPhotosBack?: Record; }) { const [colors, setColors] = useState(defaultColors.length ? defaultColors : ['#17181C']); const [picked, setPicked] = useState('#1EA7E0'); // Local previews for newly-picked files, so admins see what they just attached. const [previews, setPreviews] = useState>({}); const [previewsBack, setPreviewsBack] = useState>({}); const bulkInputRef = useRef(null); const fileInputRefs = useRef>(new Map()); const pendingFilesRef = useRef>(new Map()); const [bulkBusy, setBulkBusy] = useState(false); const [bulkResults, setBulkResults] = useState([]); const addColor = () => { if (colors.includes(picked)) return; setColors((prev) => [...prev, picked]); }; const removeColor = (c: string) => { setColors((prev) => prev.filter((x) => x !== c)); }; const handlePhotoChange = (hex: string, file: File | null, back: boolean) => { const setter = back ? setPreviewsBack : setPreviews; if (!file) { setter((prev) => { const next = { ...prev }; delete next[hex]; return next; }); return; } setter((prev) => ({ ...prev, [hex]: URL.createObjectURL(file) })); }; // Once a pending bulk-detected file's color has a rendered (i.e. its // color row just mounted), push the File into that native input via // DataTransfer and fire the same change handler a manual pick would — so // the preview and the real file both update, and the form submits it // exactly like any other per-color upload. useEffect(() => { if (pendingFilesRef.current.size === 0) return; for (const [key, file] of pendingFilesRef.current) { const input = fileInputRefs.current.get(key); if (!input) continue; const dt = new DataTransfer(); dt.items.add(file); input.files = dt.files; const [hex, variant] = key.split('__'); handlePhotoChange(hex, file, variant === 'back'); pendingFilesRef.current.delete(key); } }, [colors]); const handleBulkFiles = async (files: FileList | null) => { if (!files || files.length === 0) return; setBulkBusy(true); setBulkResults([]); const results: BulkResult[] = []; const nextColors = [...colors]; for (const file of Array.from(files)) { if (!file.type.startsWith('image/')) continue; const variant: 'front' | 'back' = /back/i.test(file.name) ? 'back' : 'front'; const detected = await detectDominantColor(file); const hex = findNearbyColor(detected, nextColors) ?? detected; if (!nextColors.includes(hex)) nextColors.push(hex); const key = `${hex}__${variant}`; pendingFilesRef.current.set(key, file); results.push({ filename: file.name, hex, variant }); } setColors(nextColors); setBulkResults(results); setBulkBusy(false); if (bulkInputRef.current) bulkInputRef.current.value = ''; }; return (

Bulk upload from a folder

Point at a folder of T-shirt photos and each one is scanned to detect the shirt's color and added automatically. Name a file with "back" in it (e.g.{' '} navy-back.jpg) to attach it as that color's back photo instead of front.

handleBulkFiles(e.target.files)} className="text-xs" /> {bulkBusy &&

Scanning images…

} {bulkResults.length > 0 && !bulkBusy && (
{bulkResults.map((r, i) => (
{r.hex} ({getColorName(r.hex)}) {r.filename} {r.variant}
))}
)}
{colors.map((c) => (
))}
setPicked(e.target.value)} className="h-9 w-9 cursor-pointer border border-line bg-paper p-0" />
{getColorName(picked)} {picked}
{colors.length === 0 &&

Add at least one color.

} {colors.length > 0 && (

Photo per color (optional)

Attach the real front and/or back photo for a color and it'll be shown exactly as-is when a customer picks that color — more accurate than tinting one photo. Leave either blank to fall back to the product's main front/back photo (or the illustration).

{colors.map((hex) => { const preview = previews[hex] ?? defaultColorPhotos[hex]; const previewBack = previewsBack[hex] ?? defaultColorPhotosBack[hex]; return (
{getColorName(hex)} {hex}
Front {preview && ( {`${hex} )} { if (el) fileInputRefs.current.set(`${hex}__front`, el); }} type="file" accept="image/*" name={frontFieldName(hex)} onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, false)} className="min-w-0 flex-1 text-xs" />
Back {previewBack && ( {`${hex} )} { if (el) fileInputRefs.current.set(`${hex}__back`, el); }} type="file" accept="image/*" name={backFieldName(hex)} onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, true)} className="min-w-0 flex-1 text-xs" />
); })}
)}
); }