Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
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="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"
|
||||
/>
|
||||
<span className="font-mono text-xs text-muted">{picked}</span>
|
||||
<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 }} />
|
||||
<span className="font-mono text-xs text-muted">{hex}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user