Files
ai-wpa/components/topics/ImageUpload.tsx
2025-08-30 18:18:57 +05:30

277 lines
7.7 KiB
TypeScript

'use client'
import React, { useState, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Upload, Image as ImageIcon, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import Image from 'next/image'
import { useToast } from '@/hooks/use-toast'
interface ImageUploadProps {
value?: string | File
onChange: (value: File | null) => void
className?: string
label?: string
disabled?: boolean
aspectRatio?: 'video' | 'square' | 'auto'
currentImage?: string
}
const ImageUpload: React.FC<ImageUploadProps> = ({
value,
onChange,
className,
label = 'Upload Cover Image',
disabled = false,
aspectRatio = 'video',
}) => {
const [isUploading, setIsUploading] = useState(false)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { toast } = useToast()
// Create preview URL when value changes
React.useEffect(() => {
if (value instanceof File) {
const url = URL.createObjectURL(value)
setPreviewUrl(url)
return () => URL.revokeObjectURL(url)
} else if (typeof value === 'string') {
setPreviewUrl(value)
} else {
setPreviewUrl(null)
}
}, [value])
const validateFile = React.useCallback(
(file: File): boolean => {
// Check file type
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file (JPEG, PNG, GIF, WebP)',
variant: 'destructive',
})
return false
}
// Check file size (5MB limit)
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
toast({
title: 'File too large',
description: 'Please select an image smaller than 5MB',
variant: 'destructive',
})
return false
}
return true
},
[toast]
)
const handleFile = useCallback(
async (file: File) => {
if (!validateFile(file)) return
try {
setIsUploading(true)
onChange(file)
toast({
title: 'Image selected',
description: 'Cover image has been updated',
})
} catch (error) {
console.error('Failed to handle file:', error)
toast({
title: 'Upload failed',
description: 'Failed to process the image',
variant: 'destructive',
})
} finally {
setIsUploading(false)
}
},
[onChange, toast, validateFile]
)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
await handleFile(file)
// Clear the input value to allow uploading the same file again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
fileInputRef.current?.click()
}
}
const handleRemove = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onChange(null)
setPreviewUrl(null)
toast({
title: 'Image removed',
description: 'Cover image has been removed',
})
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (disabled) return
const files = Array.from(e.dataTransfer.files)
const file = files[0]
if (file) {
await handleFile(file)
}
}
const getAspectRatioClass = () => {
switch (aspectRatio) {
case 'video':
return 'aspect-video'
case 'square':
return 'aspect-square'
default:
return 'min-h-[200px]'
}
}
return (
<div className={cn('space-y-4', className)}>
<input
type="file"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
disabled={disabled}
/>
{previewUrl ? (
<div
className={cn(
'relative overflow-hidden rounded-lg bg-muted border-2 border-dashed border-transparent',
getAspectRatioClass()
)}
>
<Image
src={previewUrl}
alt="Cover image preview"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
<div className="absolute inset-0 bg-black/0 hover:bg-black/10 transition-colors">
<div className="absolute top-2 right-2 flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleClick}
disabled={disabled || isUploading}
className="bg-white/90 hover:bg-white"
>
Change
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
disabled={disabled || isUploading}
className="bg-red-500/90 hover:bg-red-500"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</div>
) : (
<div
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-all duration-200',
getAspectRatioClass(),
isDragging
? 'border-primary bg-primary/5 scale-[1.02]'
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50',
disabled && 'cursor-not-allowed opacity-50'
)}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick(e as unknown as React.MouseEvent)
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={`${label} - click to browse or drag and drop`}
>
{isUploading ? (
<div className="flex flex-col items-center justify-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<p className="text-sm font-medium">Uploading image...</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3">
<div className="rounded-full bg-muted p-4">
{isDragging ? (
<Upload className="w-6 h-6 text-primary" />
) : (
<ImageIcon className="w-6 h-6 text-muted-foreground" />
)}
</div>
<div className="text-center">
<p className="font-medium text-foreground">{label}</p>
<p className="text-sm text-muted-foreground mt-1">
{isDragging ? 'Drop your image here' : 'Click to browse or drag and drop'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Supports JPEG, PNG, GIF, WebP (max 5MB)
</p>
</div>
</div>
)}
</div>
)}
</div>
)
}
export default ImageUpload