478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
|
import { Button } from './button';
|
|
import { Card } from './card';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Upload,
|
|
X,
|
|
Image,
|
|
FileText,
|
|
Video,
|
|
Music,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
|
|
/**
|
|
* FileUploadProps - Propriétés du composant FileUpload
|
|
*
|
|
* @interface FileUploadProps
|
|
*/
|
|
export interface FileUploadProps {
|
|
/**
|
|
* Fonction appelée lorsque des fichiers sont sélectionnés
|
|
*
|
|
* @param {File[]} files - Tableau de fichiers sélectionnés
|
|
*/
|
|
onFileSelect: (files: File[]) => void;
|
|
|
|
/**
|
|
* Types de fichiers acceptés (attribut accept HTML)
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <FileUpload accept="image/*" />
|
|
* <FileUpload accept=".pdf,.doc,.docx" />
|
|
* ```
|
|
*/
|
|
accept?: string;
|
|
|
|
/**
|
|
* Si `true`, permet la sélection de plusieurs fichiers
|
|
*
|
|
* @default false
|
|
*/
|
|
multiple?: boolean;
|
|
|
|
/**
|
|
* Taille maximale d'un fichier en bytes
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <FileUpload maxSize={5 * 1024 * 1024} /> // 5MB
|
|
* ```
|
|
*/
|
|
maxSize?: number;
|
|
|
|
/**
|
|
* Si `true`, affiche une preview des fichiers sélectionnés
|
|
*
|
|
* @default true
|
|
*/
|
|
showPreview?: boolean;
|
|
|
|
/**
|
|
* Si `true`, désactive le composant
|
|
*
|
|
* @default false
|
|
*/
|
|
disabled?: boolean;
|
|
|
|
/**
|
|
* Classes CSS personnalisées
|
|
*/
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* FileWithPreview - Interface étendue pour les fichiers avec preview
|
|
*
|
|
* @interface FileWithPreview
|
|
* @extends File
|
|
*/
|
|
interface FileWithPreview extends File {
|
|
/**
|
|
* URL de preview (pour les images)
|
|
*/
|
|
preview?: string;
|
|
|
|
/**
|
|
* Statut du fichier
|
|
*
|
|
* - `pending`: En attente
|
|
* - `uploading`: En cours d'upload
|
|
* - `success`: Upload réussi
|
|
* - `error`: Erreur d'upload
|
|
*/
|
|
status?: 'pending' | 'uploading' | 'success' | 'error';
|
|
|
|
/**
|
|
* Progression de l'upload (0-100)
|
|
*/
|
|
progress?: number;
|
|
|
|
/**
|
|
* Message d'erreur si applicable
|
|
*/
|
|
error?: string;
|
|
}
|
|
|
|
const FILE_ICONS = {
|
|
image: Image,
|
|
video: Video,
|
|
audio: Music,
|
|
default: FileText,
|
|
};
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
|
|
};
|
|
|
|
const getFileIcon = (file: File) => {
|
|
if (file.type && file.type.startsWith('image/')) return FILE_ICONS.image;
|
|
if (file.type && file.type.startsWith('video/')) return FILE_ICONS.video;
|
|
if (file.type && file.type.startsWith('audio/')) return FILE_ICONS.audio;
|
|
return FILE_ICONS.default;
|
|
};
|
|
|
|
const createPreview = (file: File): Promise<string | null> => {
|
|
return new Promise((resolve) => {
|
|
if (file.type && file.type.startsWith('image/')) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => resolve(e.target?.result as string);
|
|
reader.onerror = () => resolve(null);
|
|
reader.readAsDataURL(file);
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* FileUpload - Composant d'upload de fichiers avec drag & drop et preview
|
|
*
|
|
* Composant d'upload de fichiers avec support pour :
|
|
* - Drag and drop
|
|
* - Sélection par clic
|
|
* - Preview des fichiers (images)
|
|
* - Validation de taille et type
|
|
* - Affichage des erreurs
|
|
* - Support multi-fichiers
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Upload simple
|
|
* <FileUpload
|
|
* onFileSelect={(files) => handleUpload(files)}
|
|
* accept="image/*"
|
|
* />
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Upload avec validation et preview
|
|
* <FileUpload
|
|
* onFileSelect={handleFiles}
|
|
* accept=".pdf,.doc,.docx"
|
|
* multiple
|
|
* maxSize={10 * 1024 * 1024} // 10MB
|
|
* showPreview={true}
|
|
* />
|
|
* ```
|
|
*
|
|
* @component
|
|
* @param {FileUploadProps} props - Propriétés du composant
|
|
* @returns {JSX.Element} Zone d'upload avec drag-drop et preview
|
|
*/
|
|
export function FileUpload({
|
|
onFileSelect,
|
|
accept,
|
|
multiple = false,
|
|
maxSize,
|
|
showPreview = true,
|
|
disabled = false,
|
|
className,
|
|
}: FileUploadProps) {
|
|
const [dragActive, setDragActive] = useState(false);
|
|
const [files, setFiles] = useState<FileWithPreview[]>([]);
|
|
const [errors, setErrors] = useState<string[]>([]);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const validateFile = (file: File): string | null => {
|
|
// Validation du type
|
|
if (accept) {
|
|
const acceptedTypes = accept.split(',').map((type) => type.trim());
|
|
const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}`;
|
|
const fileType = file.type.toLowerCase();
|
|
|
|
const isAccepted = acceptedTypes.some((type) => {
|
|
if (type.startsWith('.')) {
|
|
return type.toLowerCase() === fileExtension;
|
|
}
|
|
if (type.includes('/')) {
|
|
return (
|
|
fileType === type.toLowerCase() ||
|
|
fileType.startsWith(`${type.toLowerCase().split('/')[0]}/`)
|
|
);
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (!isAccepted) {
|
|
return `File type ${file.type || 'unknown'} is not allowed. Accepted types: ${accept}`;
|
|
}
|
|
}
|
|
|
|
// Validation de la taille
|
|
if (maxSize && file.size > maxSize) {
|
|
return `File size ${formatFileSize(file.size)} exceeds maximum size ${formatFileSize(maxSize)}`;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const processFiles = useCallback(
|
|
async (fileList: File[]) => {
|
|
const newErrors: string[] = [];
|
|
const validFiles: FileWithPreview[] = [];
|
|
const filesToProcess: File[] = [];
|
|
|
|
// Valider tous les fichiers d'abord
|
|
fileList.forEach((file) => {
|
|
const error = validateFile(file);
|
|
if (error) {
|
|
newErrors.push(`${file.name}: ${error}`);
|
|
} else {
|
|
filesToProcess.push(file);
|
|
}
|
|
});
|
|
|
|
// Créer les previews pour les fichiers valides
|
|
for (const file of filesToProcess) {
|
|
// Create FileWithPreview by adding properties to the File object
|
|
// Since File is a class, we need to add properties directly
|
|
const fileWithPreview = file as FileWithPreview;
|
|
fileWithPreview.status = 'pending';
|
|
fileWithPreview.progress = 0;
|
|
|
|
if (showPreview) {
|
|
const preview = await createPreview(file);
|
|
if (preview) {
|
|
fileWithPreview.preview = preview;
|
|
}
|
|
}
|
|
|
|
validFiles.push(fileWithPreview);
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
|
|
if (validFiles.length > 0) {
|
|
const updatedFiles = multiple ? [...files, ...validFiles] : validFiles;
|
|
setFiles(updatedFiles);
|
|
// Pass files to parent
|
|
// FileWithPreview extends File, so we can pass it as File[]
|
|
// The internal properties (preview, status, progress, error) are not enumerable
|
|
// so they won't interfere with normal File usage
|
|
onFileSelect(updatedFiles.slice() as File[]);
|
|
}
|
|
},
|
|
[files, multiple, accept, maxSize, showPreview, onFileSelect],
|
|
);
|
|
|
|
const handleDrag = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
|
setDragActive(true);
|
|
} else if (e.type === 'dragleave') {
|
|
setDragActive(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragActive(false);
|
|
|
|
if (disabled) return;
|
|
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
const fileList = Array.from(e.dataTransfer.files);
|
|
processFiles(fileList);
|
|
}
|
|
},
|
|
[disabled, processFiles],
|
|
);
|
|
|
|
const handleFileInput = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const fileList = Array.from(e.target.files);
|
|
processFiles(fileList);
|
|
// Réinitialiser l'input pour permettre la sélection du même fichier
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
const handleRemoveFile = useCallback(
|
|
(index: number) => {
|
|
const updatedFiles = files.filter((_, i) => i !== index);
|
|
setFiles(updatedFiles);
|
|
onFileSelect(
|
|
updatedFiles.map((f) => {
|
|
// Destructure to remove internal properties before passing to parent
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { preview, status, progress, error, ...file } = f;
|
|
return file;
|
|
}),
|
|
);
|
|
},
|
|
[files, onFileSelect],
|
|
);
|
|
|
|
const handleClick = () => {
|
|
if (!disabled && fileInputRef.current) {
|
|
fileInputRef.current.click();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn('w-full', className)}>
|
|
{/* Zone de drop */}
|
|
<Card
|
|
className={cn(
|
|
'border-2 border-dashed transition-colors cursor-pointer',
|
|
dragActive && 'border-primary bg-primary/5',
|
|
disabled && 'opacity-50 cursor-not-allowed',
|
|
!disabled && 'hover:border-primary/50',
|
|
)}
|
|
onDragEnter={handleDrag}
|
|
onDragLeave={handleDrag}
|
|
onDragOver={handleDrag}
|
|
onDrop={handleDrop}
|
|
onClick={handleClick}
|
|
>
|
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
|
<Upload className="h-12 w-12 mb-4 text-muted-foreground" />
|
|
<p className="text-lg font-medium mb-2">
|
|
Drag & drop files here, or click to select
|
|
</p>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
{accept && `Accepted types: ${accept}`}
|
|
{maxSize && ` • Max size: ${formatFileSize(maxSize)}`}
|
|
{multiple && ' • Multiple files allowed'}
|
|
</p>
|
|
<Button type="button" variant="outline" disabled={disabled}>
|
|
Select Files
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Input file caché */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={accept}
|
|
multiple={multiple}
|
|
onChange={handleFileInput}
|
|
disabled={disabled}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Messages d'erreur */}
|
|
{errors.length > 0 && (
|
|
<div className="mt-4 space-y-2">
|
|
{errors.map((error, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md"
|
|
>
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Liste des fichiers avec preview */}
|
|
{showPreview && files.length > 0 && (
|
|
<div className="mt-4 space-y-3">
|
|
{files.map((file, index) => {
|
|
const IconComponent = getFileIcon(file);
|
|
const isImage = file.type && file.type.startsWith('image/');
|
|
|
|
return (
|
|
<Card key={index} className="p-4">
|
|
<div className="flex items-start gap-4">
|
|
{/* Preview */}
|
|
<div className="shrink-0">
|
|
{file.preview && isImage ? (
|
|
<img
|
|
src={file.preview}
|
|
alt={file.name}
|
|
className="w-16 h-16 object-cover rounded-md"
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-16 flex items-center justify-center bg-muted rounded-md">
|
|
<IconComponent className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info fichier */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<p className="text-sm font-medium truncate">
|
|
{file.name}
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 shrink-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveFile(index);
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{formatFileSize(file.size)} •{' '}
|
|
{file.type || 'Unknown type'}
|
|
</p>
|
|
|
|
{/* Barre de progression */}
|
|
{file.status === 'uploading' && (
|
|
<div className="w-full bg-muted rounded-full h-2">
|
|
<div
|
|
className="bg-primary h-2 rounded-full transition-all"
|
|
style={{ width: `${file.progress || 0}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status */}
|
|
{file.status === 'success' && (
|
|
<div className="flex items-center gap-1 text-xs text-kodo-lime">
|
|
<CheckCircle className="h-3 w-3" />
|
|
<span>Uploaded successfully</span>
|
|
</div>
|
|
)}
|
|
{file.status === 'error' && file.error && (
|
|
<div className="flex items-center gap-1 text-xs text-destructive">
|
|
<AlertCircle className="h-3 w-3" />
|
|
<span>{file.error}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|