feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
All checks were successful
Deploy to Production / test (push) Successful in 44s
All checks were successful
Deploy to Production / test (push) Successful in 44s
This commit is contained in:
230
web/src/components/image-uploader.tsx
Normal file
230
web/src/components/image-uploader.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 = 2; // 2MB
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
setIsLoading(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError("Failed to read file");
|
||||
setIsLoading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
onFileSelect(file);
|
||||
}, [onFileSelect, validateFile]);
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageUploader;
|
||||
Reference in New Issue
Block a user