Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+313
View File
@@ -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&apos;s
color and added automatically. Name a file with &quot;back&quot; in it (e.g.{' '}
<code>navy-back.jpg</code>) to attach it as that color&apos;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&apos;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&apos;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>
);
}