diff --git a/src/components/shared/ImageUploader.tsx b/src/components/shared/ImageUploader.tsx new file mode 100644 index 0000000..4dc03a5 --- /dev/null +++ b/src/components/shared/ImageUploader.tsx @@ -0,0 +1,134 @@ +import { useCallback, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { Upload, X, Image as ImageIcon, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { useImageUpload } from '@/hooks/useImageUpload'; +import { cn } from '@/lib/utils'; + +interface ImageUploaderProps { + onUploadComplete: (urls: string[]) => void; + maxFiles?: number; + category?: string; + className?: string; + multiple?: boolean; +} + +export const ImageUploader = ({ + onUploadComplete, + maxFiles = 5, + category, + className, + multiple = true, +}: ImageUploaderProps) => { + const [previewUrls, setPreviewUrls] = useState([]); + const { uploadSingle, uploadMultiple, uploading, progress } = useImageUpload({ category }); + + const onDrop = useCallback( + async (acceptedFiles: File[]) => { + // Create preview URLs + const previews = acceptedFiles.map((file) => URL.createObjectURL(file)); + setPreviewUrls(previews); + + // Upload files + if (multiple && acceptedFiles.length > 1) { + const response = await uploadMultiple(acceptedFiles); + if (response?.success) { + const urls = response.uploads.map((upload) => upload.url); + onUploadComplete(urls); + } else { + // Clear previews on error + setPreviewUrls([]); + } + } else { + const response = await uploadSingle(acceptedFiles[0]); + if (response?.success) { + onUploadComplete([response.url]); + } else { + // Clear previews on error + setPreviewUrls([]); + } + } + }, + [multiple, uploadSingle, uploadMultiple, onUploadComplete] + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/webp': ['.webp'], + 'image/gif': ['.gif'], + }, + maxFiles: multiple ? maxFiles : 1, + multiple, + disabled: uploading, + }); + + const removePreview = (index: number) => { + setPreviewUrls((prev) => prev.filter((_, i) => i !== index)); + }; + + return ( +
+
+ +
+ {uploading ? ( + + ) : ( + + )} +
+

+ {isDragActive + ? 'Suelta las imágenes aquí...' + : 'Arrastra y suelta imágenes aquí, o haz clic para seleccionar'} +

+

+ JPG, PNG, WEBP, GIF hasta 5MB {multiple && `(máximo ${maxFiles} archivos)`} +

+
+
+
+ + {uploading && ( +
+
+ Subiendo... + {progress}% +
+ +
+ )} + + {previewUrls.length > 0 && ( +
+ {previewUrls.map((url, index) => ( +
+ {`Preview + {!uploading && ( + + )} +
+ ))} +
+ )} +
+ ); +}; diff --git a/src/hooks/useImageUpload.ts b/src/hooks/useImageUpload.ts new file mode 100644 index 0000000..25b13b9 --- /dev/null +++ b/src/hooks/useImageUpload.ts @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { adminApi, UploadResponse, MultiUploadResponse } from '@/services/adminApi'; +import { useToast } from '@/hooks/use-toast'; + +interface UseImageUploadOptions { + maxSize?: number; // in bytes, default 5MB + allowedTypes?: string[]; + category?: string; +} + +export const useImageUpload = (options: UseImageUploadOptions = {}) => { + const { + maxSize = 5 * 1024 * 1024, // 5MB default + allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'], + category, + } = options; + + const { toast } = useToast(); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + + const validateFile = (file: File): string | null => { + if (!allowedTypes.includes(file.type)) { + return `Tipo de archivo no permitido. Solo se permiten: ${allowedTypes.join(', ')}`; + } + + if (file.size > maxSize) { + return `El archivo es demasiado grande. Tamaño máximo: ${(maxSize / 1024 / 1024).toFixed(2)}MB`; + } + + return null; + }; + + const uploadSingle = async (file: File): Promise => { + const error = validateFile(file); + if (error) { + toast({ + title: 'Error de validación', + description: error, + variant: 'destructive', + }); + return null; + } + + setUploading(true); + setProgress(0); + + try { + // Simulate progress + const progressInterval = setInterval(() => { + setProgress((prev) => Math.min(prev + 10, 90)); + }, 200); + + const response = await adminApi.uploadImage(file, category); + + clearInterval(progressInterval); + setProgress(100); + + toast({ + title: 'Imagen subida', + description: 'La imagen se ha subido correctamente', + }); + + return response; + } catch (error) { + console.error('Error uploading image:', error); + toast({ + title: 'Error al subir imagen', + description: error instanceof Error ? error.message : 'No se pudo subir la imagen', + variant: 'destructive', + }); + return null; + } finally { + setUploading(false); + setTimeout(() => setProgress(0), 1000); + } + }; + + const uploadMultiple = async (files: File[]): Promise => { + // Validate all files first + for (const file of files) { + const error = validateFile(file); + if (error) { + toast({ + title: 'Error de validación', + description: `${file.name}: ${error}`, + variant: 'destructive', + }); + return null; + } + } + + setUploading(true); + setProgress(0); + + try { + // Simulate progress + const progressInterval = setInterval(() => { + setProgress((prev) => Math.min(prev + 10, 90)); + }, 300); + + const response = await adminApi.uploadImages(files, category); + + clearInterval(progressInterval); + setProgress(100); + + toast({ + title: 'Imágenes subidas', + description: `${files.length} imagen(es) subida(s) correctamente`, + }); + + return response; + } catch (error) { + console.error('Error uploading images:', error); + toast({ + title: 'Error al subir imágenes', + description: error instanceof Error ? error.message : 'No se pudieron subir las imágenes', + variant: 'destructive', + }); + return null; + } finally { + setUploading(false); + setTimeout(() => setProgress(0), 1000); + } + }; + + return { + uploadSingle, + uploadMultiple, + uploading, + progress, + validateFile, + }; +}; diff --git a/src/services/adminApi.ts b/src/services/adminApi.ts index 77adf21..740ded1 100644 --- a/src/services/adminApi.ts +++ b/src/services/adminApi.ts @@ -154,12 +154,15 @@ class ApiClient { }); } - async postForm(endpoint: string, data: Record, headers?: Record): Promise { - const formBody = new URLSearchParams(data).toString(); + async postForm(endpoint: string, data: Record | FormData, headers?: Record): Promise { + const isFormData = data instanceof FormData; + const body = isFormData ? data : new URLSearchParams(data).toString(); + const contentType = isFormData ? undefined : 'application/x-www-form-urlencoded'; + return this.request(endpoint, { method: 'POST', - body: formBody, - headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(headers || {}) }, + body, + headers: contentType ? { 'Content-Type': contentType, ...(headers || {}) } : headers, }); } @@ -181,6 +184,30 @@ export const apiClient = new ApiClient(API_BASE_URL); // Admin API Services export const adminApi = { + // ============================================================================= + // UPLOAD MANAGEMENT + // ============================================================================= + + uploadImage: async (file: File, category?: string) => { + const formData = new FormData(); + formData.append('image', file); + if (category) { + formData.append('category', category); + } + return apiClient.postForm('/upload/image', formData); + }, + + uploadImages: async (files: File[], category?: string) => { + const formData = new FormData(); + files.forEach((file) => { + formData.append('images', file); + }); + if (category) { + formData.append('category', category); + } + return apiClient.postForm('/upload/images', formData); + }, + // ============================================================================= // AUTHENTICATION & USER MANAGEMENT // ============================================================================= @@ -587,4 +614,24 @@ export interface CommissionRate { createdAt: string; updatedAt: string; updatedBy?: string; +} + +export interface UploadResponse { + success: boolean; + url: string; + key: string; + bucket: string; + size: number; + contentType: string; +} + +export interface MultiUploadResponse { + success: boolean; + uploads: { + url: string; + key: string; + bucket: string; + size: number; + contentType: string; + }[]; } \ No newline at end of file