All checks were successful
CI / ci (push) Successful in 15s
Run biome check --write --unsafe to fix tabs, import ordering, and non-null assertions across entire codebase. Disable a11y rules not applicable to this single-user app. Exclude auto-generated routeTree. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
145 lines
3.8 KiB
TypeScript
145 lines
3.8 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import { apiUpload } from "../lib/api";
|
|
|
|
interface ImageUploadProps {
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
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 = "";
|
|
}
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|