299 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|