- Add colorNames utility with 80+ standard color names - Map hex codes to human-readable names (Black, Navy, Red, Teal, etc) - Display color names in product page (with hex tooltip for reference) - Show selected color name below color swatches - Update shopping cart to display color names instead of hex codes - Admin color picker shows both name and hex code for clarity Improves UX for customers who don't understand hex color codes. Keeps hex codes for internal data and system tooltips. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
'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<HTMLImageElement> {
|
||
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<string> {
|
||
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<string, string>;
|
||
defaultColorPhotosBack?: Record<string, string>;
|
||
}) {
|
||
const [colors, setColors] = useState<string[]>(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<Record<string, string>>({});
|
||
const [previewsBack, setPreviewsBack] = useState<Record<string, string>>({});
|
||
|
||
const bulkInputRef = useRef<HTMLInputElement | null>(null);
|
||
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
||
const pendingFilesRef = useRef<Map<string, File>>(new Map());
|
||
const [bulkBusy, setBulkBusy] = useState(false);
|
||
const [bulkResults, setBulkResults] = useState<BulkResult[]>([]);
|
||
|
||
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 <input> (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 (
|
||
<div>
|
||
<input type="hidden" name={name} value={colors.join(',')} />
|
||
|
||
<div className="mb-4 border border-line bg-surface p-3">
|
||
<p className="tag-label mb-1">Bulk upload from a folder</p>
|
||
<p className="mb-2 text-xs text-muted">
|
||
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.{' '}
|
||
<code>navy-back.jpg</code>) to attach it as that color's back photo instead of front.
|
||
</p>
|
||
<input
|
||
ref={bulkInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
// @ts-expect-error non-standard attributes for folder selection, supported in Chromium/Firefox
|
||
webkitdirectory=""
|
||
directory=""
|
||
onChange={(e) => handleBulkFiles(e.target.files)}
|
||
className="text-xs"
|
||
/>
|
||
{bulkBusy && <p className="mt-2 text-xs text-muted">Scanning images…</p>}
|
||
{bulkResults.length > 0 && !bulkBusy && (
|
||
<div className="mt-3 space-y-1">
|
||
{bulkResults.map((r, i) => (
|
||
<div key={`${r.filename}-${i}`} className="flex items-center gap-2 text-xs">
|
||
<div className="h-4 w-4 shrink-0 rounded-full border border-line" style={{ backgroundColor: r.hex }} />
|
||
<span className="font-mono text-muted">{r.hex}</span>
|
||
<span className="text-muted">({getColorName(r.hex)})</span>
|
||
<span className="truncate text-muted">{r.filename}</span>
|
||
<span className="tag-label shrink-0 text-muted">{r.variant}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-3">
|
||
{colors.map((c) => (
|
||
<div key={c} className="group relative">
|
||
<div
|
||
className="h-10 w-10 rounded-full border border-line"
|
||
style={{ backgroundColor: c }}
|
||
title={c}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeColor(c)}
|
||
aria-label={`Remove ${c}`}
|
||
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full border border-line bg-paper text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:border-splash-pink hover:text-splash-pink"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center gap-3">
|
||
<input
|
||
type="color"
|
||
value={picked}
|
||
onChange={(e) => setPicked(e.target.value)}
|
||
className="h-9 w-9 cursor-pointer border border-line bg-paper p-0"
|
||
/>
|
||
<div className="flex flex-col gap-0.5">
|
||
<span className="text-xs font-medium">{getColorName(picked)}</span>
|
||
<span className="font-mono text-xs text-muted">{picked}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={addColor}
|
||
className="border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
|
||
>
|
||
+ Add color
|
||
</button>
|
||
</div>
|
||
|
||
{colors.length === 0 && <p className="mt-2 text-xs text-splash-pink">Add at least one color.</p>}
|
||
|
||
{colors.length > 0 && (
|
||
<div className="mt-5 border-t border-line pt-4">
|
||
<p className="tag-label mb-1">Photo per color (optional)</p>
|
||
<p className="mb-3 text-xs text-muted">
|
||
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).
|
||
</p>
|
||
<div className="space-y-3">
|
||
{colors.map((hex) => {
|
||
const preview = previews[hex] ?? defaultColorPhotos[hex];
|
||
const previewBack = previewsBack[hex] ?? defaultColorPhotosBack[hex];
|
||
return (
|
||
<div key={hex} className="border border-line p-2">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<div className="h-6 w-6 shrink-0 rounded-full border border-line" style={{ backgroundColor: hex }} />
|
||
<div className="flex flex-col gap-0.5">
|
||
<span className="font-medium text-xs">{getColorName(hex)}</span>
|
||
<span className="font-mono text-xs text-muted">{hex}</span>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="tag-label w-10 shrink-0">Front</span>
|
||
{preview && (
|
||
<img src={preview} alt={`${hex} front preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
|
||
)}
|
||
<input
|
||
ref={(el) => {
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="tag-label w-10 shrink-0">Back</span>
|
||
{previewBack && (
|
||
<img src={previewBack} alt={`${hex} back preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
|
||
)}
|
||
<input
|
||
ref={(el) => {
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|