Files
aurorabot/web/src/components/image-uploader.tsx
syntaxbullet ee088ad84b
All checks were successful
Deploy to Production / test (push) Successful in 40s
feat: Increase maximum image upload size from 2MB to 15MB.
2026-02-06 13:48:43 +01:00

260 lines
8.8 KiB
TypeScript

/**
* ImageUploader Component
* Drag-and-drop image upload zone with preview.
*/
import { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Upload, X, Image as ImageIcon, Loader2, AlertCircle } from "lucide-react";
import { ImageCropper } from "@/components/image-cropper";
interface ImageUploaderProps {
existingUrl?: string | null;
onFileSelect: (file: File | null) => void;
disabled?: boolean;
maxSizeMB?: number;
}
const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const MAX_SIZE_DEFAULT = 15; // 15MB
export function ImageUploader({
existingUrl,
onFileSelect,
disabled,
maxSizeMB = MAX_SIZE_DEFAULT,
}: ImageUploaderProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(existingUrl || null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Cropper state
const [isCropperOpen, setIsCropperOpen] = useState(false);
const [tempImageSrc, setTempImageSrc] = useState<string | null>(null);
const [tempFileName, setTempFileName] = useState<string>("image.jpg");
const inputRef = useRef<HTMLInputElement>(null);
const validateFile = useCallback((file: File): string | null => {
if (!ACCEPTED_TYPES.includes(file.type)) {
return "Invalid file type. Please use PNG, JPEG, WebP, or GIF.";
}
if (file.size > maxSizeMB * 1024 * 1024) {
return `File too large. Maximum size is ${maxSizeMB}MB.`;
}
return null;
}, [maxSizeMB]);
const handleFile = useCallback((file: File) => {
setError(null);
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
setIsLoading(true);
setTempFileName(file.name);
// Read file for cropper
const reader = new FileReader();
reader.onload = () => {
setTempImageSrc(reader.result as string);
setIsCropperOpen(true);
setIsLoading(false);
};
reader.onerror = () => {
setError("Failed to read file");
setIsLoading(false);
};
reader.readAsDataURL(file);
}, [validateFile]);
const handleCropComplete = useCallback((croppedBlob: Blob) => {
// Convert blob back to file
const file = new File([croppedBlob], tempFileName, { type: "image/jpeg" });
// Create preview
const url = URL.createObjectURL(croppedBlob);
setPreviewUrl(url);
// Pass to parent
onFileSelect(file);
setIsCropperOpen(false);
setTempImageSrc(null);
}, [onFileSelect, tempFileName]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = e.dataTransfer.files;
const file = files[0];
if (file) {
handleFile(file);
}
}, [disabled, handleFile]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
const file = files?.[0];
if (file) {
handleFile(file);
}
}, [handleFile]);
const handleRemove = useCallback(() => {
setPreviewUrl(null);
setError(null);
onFileSelect(null);
if (inputRef.current) {
inputRef.current.value = "";
}
}, [onFileSelect]);
const handleClick = useCallback(() => {
if (!disabled) {
inputRef.current?.click();
}
}, [disabled]);
// Show preview state
if (previewUrl) {
return (
<div className="relative">
<div className="relative aspect-square max-w-[200px] rounded-lg overflow-hidden border border-border/50 bg-muted/30">
<img
src={previewUrl}
alt="Item preview"
className="w-full h-full object-contain"
/>
{!disabled && (
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleClick}
>
Replace
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
onChange={handleInputChange}
className="hidden"
/>
</div>
);
}
// Show upload zone
return (
<div className="space-y-2">
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={cn(
"relative flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg transition-colors cursor-pointer",
isDragging && "border-primary bg-primary/5",
error && "border-destructive bg-destructive/5",
!isDragging && !error && "border-border/50 hover:border-border bg-muted/10",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{isLoading ? (
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
) : error ? (
<AlertCircle className="h-8 w-8 text-destructive" />
) : (
<div className="flex flex-col items-center gap-2 text-center">
{isDragging ? (
<Upload className="h-8 w-8 text-primary animate-bounce" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
)}
<div className="text-sm text-muted-foreground">
{isDragging ? (
<p className="text-primary font-medium">Drop to upload</p>
) : (
<>
<p className="font-medium">Drop image or click to browse</p>
<p className="text-xs mt-1">
PNG, JPEG, WebP, GIF Max {maxSizeMB}MB
</p>
</>
)}
</div>
</div>
)}
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
onChange={handleInputChange}
className="hidden"
disabled={disabled}
/>
</div>
{error && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{error}
</p>
)}
<ImageCropper
isOpen={isCropperOpen}
onClose={() => setIsCropperOpen(false)}
onCropComplete={handleCropComplete}
imageSrc={tempImageSrc}
/>
</div>
);
}
export default ImageUploader;