ifixkart-admin/components/ui/ImageUpload.tsx

299 lines
10 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Upload, X, Loader2, Image as ImageIcon } from 'lucide-react';
import { toast } from 'sonner';
import { getAccessToken } from '@/services/api/client';
import { API_BASE_URL } from '@/services/api/config';
interface ImageUploadProps {
entityType: string;
entityId?: string;
onUploadSuccess: (url: string) => void;
onBatchUpload?: (urls: string[]) => void;
value?: string;
onClear?: () => void;
className?: string;
size?: 'sm' | 'md';
}
export default function ImageUpload({
entityType,
entityId,
onUploadSuccess,
onBatchUpload,
value,
onClear,
className = '',
size = 'md',
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [localPreview, setLocalPreview] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalPreview(value || '');
}, [value]);
// Generate a random ID if not provided, for temporary upload directory mapping
const resolveEntityId = () => {
if (entityId && entityId.trim() !== '') return entityId;
return 'temp_' + Math.random().toString(36).substring(2, 11);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
if (e.target.files.length > 1) {
uploadMultipleFiles(Array.from(e.target.files));
} else {
uploadFile(e.target.files[0]);
}
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
if (e.dataTransfer.files.length > 1) {
uploadMultipleFiles(Array.from(e.dataTransfer.files));
} else {
uploadFile(e.dataTransfer.files[0]);
}
}
};
const triggerFileSelect = () => {
fileInputRef.current?.click();
};
const uploadFile = async (file: File) => {
// Basic file validation
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
toast.error('Only JPG, JPEG, PNG, or WebP images are allowed');
return;
}
const maxSize = 20 * 1024 * 1024; // 20MB
if (file.size > maxSize) {
toast.error('Image is larger than 20MB');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
formData.append('entity_id', resolveEntityId());
try {
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/v1/files/upload`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Upload failed' }));
throw new Error(errorData.detail || 'Upload failed');
}
const data = await response.json();
// Format the public URL paths properly (prefer webp_path, fallback to storage_path / raw_path)
const rawPath = data?.webp_path || data?.storage_path || data?.raw_path || data?.url || '';
const imageUrl = rawPath ? (rawPath.startsWith('/') ? rawPath : `/${rawPath}`) : '';
setLocalPreview(imageUrl);
onUploadSuccess(imageUrl);
toast.success('Image uploaded');
} catch (err: any) {
toast.error(err?.message || 'Could not upload the image. Check that the server is running.');
console.error('File upload error:', err);
} finally {
setUploading(false);
}
};
const uploadMultipleFiles = async (files: File[]) => {
const allowedExtensions = ['jpg', 'jpeg', 'png', 'webp'];
const validFiles = files.filter((f) => {
const ext = f.name.split('.').pop()?.toLowerCase() || '';
return (f.type && f.type.startsWith('image/')) || allowedExtensions.includes(ext);
});
if (validFiles.length === 0) {
toast.error('Only JPG, JPEG, PNG, or WebP images are allowed');
return;
}
setUploading(true);
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const uploads = await Promise.all(
validFiles.map(async (file) => {
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
formData.append('entity_id', resolveEntityId());
const res = await fetch(`${API_BASE_URL}/api/v1/files/upload`, {
method: 'POST',
headers,
body: formData,
});
if (res.ok) {
const data = await res.json();
const rawPath = data?.webp_path || data?.storage_path || data?.raw_path || data?.url || '';
if (rawPath) {
return rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
}
}
return null;
})
);
const urls = uploads.filter((u): u is string => Boolean(u));
if (urls.length > 0) {
toast.success(`Uploaded ${urls.length} image(s)`);
if (onBatchUpload) {
onBatchUpload(urls);
} else {
setLocalPreview(urls[0]);
onUploadSuccess(urls[0]);
}
} else {
toast.error('Failed to upload image(s)');
}
} catch (err: any) {
toast.error('Upload failed');
console.error(err);
} finally {
setUploading(false);
}
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
setLocalPreview('');
if (onClear) {
onClear();
} else {
onUploadSuccess('');
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const activeValue = localPreview || value || '';
const hasValue = Boolean(activeValue && typeof activeValue === 'string' && activeValue.trim() !== '');
const displaySrc = hasValue
? activeValue.startsWith('http://') || activeValue.startsWith('https://')
? activeValue
: `${API_BASE_URL}${activeValue.startsWith('/') ? activeValue : `/${activeValue}`}`
: '';
const compact = size === 'sm';
return (
<div className={`relative ${className}`}>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".jpg,.jpeg,.png,.webp"
className="hidden"
/>
{hasValue && displaySrc ? (
<div
onClick={triggerFileSelect}
className={`relative group w-full overflow-hidden border border-border bg-muted/50 flex items-center cursor-pointer hover:border-primary/40 transition-all ${
compact ? 'h-9 crm-radius-control px-1.5 gap-2' : 'h-32 rounded-[5px] justify-center'
}`}
>
<img
src={displaySrc}
alt="Upload Preview"
className={compact ? 'h-7 w-7 object-cover crm-radius-toggle shrink-0' : 'w-full h-full object-contain transition-transform duration-300 group-hover:scale-105'}
/>
{compact ? (
<span className="text-[13px] text-foreground truncate flex-1">Image selected</span>
) : (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity duration-200">
<span className="text-xs font-semibold text-white bg-black/60 px-2.5 py-1.5 rounded-md backdrop-blur-xs">
Change Image
</span>
</div>
)}
<button
type="button"
onClick={handleClear}
className={`absolute p-1 bg-red-600/90 hover:bg-red-600 text-white rounded-full shadow-lg transition-all cursor-pointer z-10 hover:scale-115 active:scale-95 ${
compact ? 'top-1.5 right-1.5' : 'top-2 right-2 p-1.5'
}`}
title="Remove Image"
>
<X className={compact ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
</button>
</div>
) : (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={triggerFileSelect}
className={`w-full border-2 border-dashed flex cursor-pointer transition-all ${
compact
? 'h-9 crm-radius-control flex-row items-center gap-2 px-3'
: 'h-32 rounded-[5px] flex-col items-center justify-center p-4 text-center'
} ${isDragOver ? 'border-red-500 bg-red-500/10' : 'border-border bg-muted/50 hover:border-border hover:bg-muted'}`}
>
{uploading ? (
<div className={`flex items-center ${compact ? 'gap-2' : 'space-y-2 flex-col'}`}>
<Loader2 className={`${compact ? 'w-4 h-4' : 'w-8 h-8'} text-red-500 animate-spin`} />
<span className="text-xs text-muted-foreground">Uploading...</span>
</div>
) : compact ? (
<>
<Upload className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-[13px] text-muted-foreground truncate">
<span className="font-medium text-foreground">Upload image</span>
<span className="hidden sm:inline"> ยท PNG, JPG, WEBP</span>
</span>
</>
) : (
<div className="space-y-1.5 flex flex-col items-center">
<Upload className="w-7 h-7 text-muted-foreground" />
<div>
<span className="text-xs font-semibold text-red-400 hover:underline">Click to upload</span>
<span className="text-xs text-muted-foreground"> or drag and drop</span>
</div>
<span className="text-[10px] text-muted-foreground uppercase font-mono">PNG, JPG, WEBP (Max 20MB)</span>
</div>
)}
</div>
)}
</div>
);
}