Files
GearBox/src/client/components/ImageUpload.tsx
Jean-Luc Makiola a18b9d37bd feat(29-03): add crop editor to item and candidate detail pages
Add "Adjust framing" button to item detail and candidate detail
pages. Crop editor appears inline, persists via update mutations.
Fix lint issues in ImageCropEditor import ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:08:08 +02:00

193 lines
5.2 KiB
TypeScript

import { useRef, useState } from "react";
import { apiUpload } from "../lib/api";
import { GearImage, imageContainerBg } from "./GearImage";
import { ImageCropEditor } from "./ImageCropEditor";
interface ImageUploadProps {
value: string | null;
imageUrl?: string | null;
dominantColor?: string | null;
onChange: (filename: string | null) => void;
onCropChange?: (crop: { zoom: number; x: number; y: number }) => void;
}
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
export function ImageUpload({
value: _value,
imageUrl,
dominantColor,
onChange,
onCropChange,
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [localPreview, setLocalPreview] = useState<string | null>(null);
const [showCropEditor, setShowCropEditor] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Please select a JPG, PNG, or WebP image.");
return;
}
if (file.size > MAX_SIZE_BYTES) {
setError("Image must be under 5MB.");
return;
}
// Create local preview for immediate display
const previewUrl = URL.createObjectURL(file);
setLocalPreview(previewUrl);
setUploading(true);
try {
const result = await apiUpload<{ filename: string }>("/api/images", file);
onChange(result.filename);
if (onCropChange) {
setShowCropEditor(true);
}
} catch {
setError("Upload failed. Please try again.");
setLocalPreview(null);
} finally {
setUploading(false);
// Reset input so the same file can be re-selected
if (inputRef.current) inputRef.current.value = "";
}
}
function handleRemove(e: React.MouseEvent) {
e.stopPropagation();
setLocalPreview(null);
onChange(null);
}
// Determine the display URL: local preview takes priority (just-uploaded),
// then presigned URL from API, then nothing
const displayUrl = localPreview || imageUrl || null;
return (
<div>
{/* Crop editor overlay */}
{showCropEditor && displayUrl && onCropChange && (
<div className="mb-4">
<ImageCropEditor
imageUrl={displayUrl}
dominantColor={dominantColor}
onSave={(result) => {
onCropChange(result);
setShowCropEditor(false);
}}
onCancel={() => setShowCropEditor(false)}
/>
</div>
)}
{/* Hero image area */}
{!showCropEditor && (
<div
onClick={() => inputRef.current?.click()}
className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
style={{
backgroundColor: displayUrl
? imageContainerBg(dominantColor)
: undefined,
}}
>
{displayUrl ? (
<>
<GearImage
src={displayUrl}
alt="Item"
dominantColor={dominantColor}
/>
{/* Remove button */}
<button
type="button"
onClick={handleRemove}
className="absolute top-2 right-2 w-7 h-7 flex items-center justify-center bg-white/80 hover:bg-white rounded-full text-gray-600 hover:text-gray-900 transition-colors shadow-sm"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</>
) : (
<div className="w-full h-full bg-gray-100 flex flex-col items-center justify-center">
{/* ImagePlus icon */}
<svg
className="w-10 h-10 text-gray-300 group-hover:text-gray-400 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
<path d="M14 4v3" />
<path d="M12.5 5.5h3" />
</svg>
<span className="mt-2 text-sm text-gray-400 group-hover:text-gray-500 transition-colors">
Click to add photo
</span>
</div>
)}
{/* Upload spinner overlay */}
{uploading && (
<div className="absolute inset-0 bg-white/60 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-500 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
className="hidden"
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}