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 (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */}
<div>
<label
@@ -258,19 +266,6 @@ export function CandidateForm({
)}
</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 */}
<div className="flex gap-3 pt-2">
<button

View File

@@ -2,94 +2,146 @@ import { useState, useRef } from "react";
import { apiUpload } from "../lib/api";
interface ImageUploadProps {
value: string | null;
onChange: (filename: string | null) => void;
value: string | null;
onChange: (filename: string | null) => void;
}
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
export function ImageUpload({ value, onChange }: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setError(null);
if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Please select a JPG, PNG, or WebP image.");
return;
}
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;
}
if (file.size > MAX_SIZE_BYTES) {
setError("Image must be under 5MB.");
return;
}
setUploading(true);
try {
const result = await apiUpload<{ filename: string }>(
"/api/images",
file,
);
onChange(result.filename);
} catch {
setError("Upload failed. Please try again.");
} finally {
setUploading(false);
}
}
setUploading(true);
try {
const result = await apiUpload<{ filename: string }>(
"/api/images",
file,
);
onChange(result.filename);
} catch {
setError("Upload failed. Please try again.");
} finally {
setUploading(false);
// Reset input so the same file can be re-selected
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div>
{value && (
<div className="mb-2 relative">
<img
src={`/uploads/${value}`}
alt="Item"
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
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"
>
<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>
)}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
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"
>
{uploading ? "Uploading..." : value ? "Change image" : "Add image"}
</button>
<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>
);
function handleRemove(e: React.MouseEvent) {
e.stopPropagation();
onChange(null);
}
return (
<div>
{/* Hero image area */}
<div
onClick={() => inputRef.current?.click()}
className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
>
{value ? (
<>
<img
src={`/uploads/${value}`}
alt="Item"
className="w-full h-full object-cover"
/>
{/* 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>
);
}

View File

@@ -118,6 +118,14 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */}
<div>
<label
@@ -242,19 +250,6 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
)}
</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 */}
<div className="flex gap-3 pt-2">
<button