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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user