feat(05-01): redesign ImageUpload as hero area and move to top of forms

- Full-width 4:3 aspect ratio hero image area with rounded corners
- Placeholder state: gray background with ImagePlus icon and helper text
- Preview state: object-cover image with circular X remove button
- Upload state: semi-transparent spinner overlay
- Entire area clickable to upload/replace image
- Moved ImageUpload to first element in both ItemForm and CandidateForm
- Removed redundant "Image" label wrappers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 17:11:18 +01:00
parent 8c0529cd60
commit 3243be433f
3 changed files with 146 additions and 104 deletions

View File

@@ -134,6 +134,14 @@ export function CandidateForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */} {/* Name */}
<div> <div>
<label <label
@@ -258,19 +266,6 @@ export function CandidateForm({
)} )}
</div> </div>
{/* Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Image
</label>
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
</div>
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button

View File

@@ -2,94 +2,146 @@ import { useState, useRef } from "react";
import { apiUpload } from "../lib/api"; import { apiUpload } from "../lib/api";
interface ImageUploadProps { interface ImageUploadProps {
value: string | null; value: string | null;
onChange: (filename: string | null) => void; onChange: (filename: string | null) => void;
} }
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
export function ImageUpload({ value, onChange }: ImageUploadProps) { export function ImageUpload({ value, onChange }: ImageUploadProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setError(null); setError(null);
if (!ACCEPTED_TYPES.includes(file.type)) { if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Please select a JPG, PNG, or WebP image."); setError("Please select a JPG, PNG, or WebP image.");
return; return;
} }
if (file.size > MAX_SIZE_BYTES) { if (file.size > MAX_SIZE_BYTES) {
setError("Image must be under 5MB."); setError("Image must be under 5MB.");
return; return;
} }
setUploading(true); setUploading(true);
try { try {
const result = await apiUpload<{ filename: string }>( const result = await apiUpload<{ filename: string }>(
"/api/images", "/api/images",
file, file,
); );
onChange(result.filename); onChange(result.filename);
} catch { } catch {
setError("Upload failed. Please try again."); setError("Upload failed. Please try again.");
} finally { } finally {
setUploading(false); setUploading(false);
} // Reset input so the same file can be re-selected
} if (inputRef.current) inputRef.current.value = "";
}
}
return ( function handleRemove(e: React.MouseEvent) {
<div> e.stopPropagation();
{value && ( onChange(null);
<div className="mb-2 relative"> }
<img
src={`/uploads/${value}`} return (
alt="Item" <div>
className="w-full h-32 object-cover rounded-lg" {/* Hero image area */}
/> <div
<button onClick={() => inputRef.current?.click()}
type="button" className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
onClick={() => onChange(null)} >
className="absolute top-1 right-1 p-1 bg-white/80 hover:bg-white rounded-full text-gray-600 hover:text-gray-900" {value ? (
> <>
<svg <img
className="w-4 h-4" src={`/uploads/${value}`}
fill="none" alt="Item"
stroke="currentColor" className="w-full h-full object-cover"
viewBox="0 0 24 24" />
> {/* Remove button */}
<path <button
strokeLinecap="round" type="button"
strokeLinejoin="round" onClick={handleRemove}
strokeWidth={2} 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"
d="M6 18L18 6M6 6l12 12" >
/> <svg
</svg> className="w-4 h-4"
</button> fill="none"
</div> stroke="currentColor"
)} viewBox="0 0 24 24"
<button >
type="button" <path
onClick={() => inputRef.current?.click()} strokeLinecap="round"
disabled={uploading} strokeLinejoin="round"
className="w-full py-2 px-3 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50" strokeWidth={2}
> d="M6 18L18 6M6 6l12 12"
{uploading ? "Uploading..." : value ? "Change image" : "Add image"} />
</button> </svg>
<input </button>
ref={inputRef} </>
type="file" ) : (
accept="image/jpeg,image/png,image/webp" <div className="w-full h-full bg-gray-100 flex flex-col items-center justify-center">
onChange={handleFileChange} {/* ImagePlus icon */}
className="hidden" <svg
/> className="w-10 h-10 text-gray-300 group-hover:text-gray-400 transition-colors"
{error && <p className="mt-1 text-xs text-red-500">{error}</p>} fill="none"
</div> 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>
);
} }

View File

@@ -118,6 +118,14 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */} {/* Name */}
<div> <div>
<label <label
@@ -242,19 +250,6 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
)} )}
</div> </div>
{/* Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Image
</label>
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
</div>
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button