veza/apps/web/src/features/upload/components/UploadModal.tsx

486 lines
16 KiB
TypeScript

// CRITICAL FIX: Import React FIRST to ensure it's available
import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
// TEMPORARY FIX: Comment out react-hook-form to isolate initialization error
// import { useForm } from 'react-hook-form';
import { Dialog, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert } from '@/components/ui/alert';
import {
Upload,
X,
FileAudio,
AlertCircle,
CheckCircle2,
RefreshCw,
} from 'lucide-react';
import { tracksApi, type TrackMetadata } from '@/services/api/tracks';
import { useQueryClient } from '@tanstack/react-query';
import { LIBRARY_KEYS } from '@/features/library/hooks/useMyTracks';
import { logger } from '@/utils/logger';
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
export interface UploadModalProps {
open: boolean;
onClose: () => void;
}
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
const ACCEPTED_AUDIO_TYPES = {
'audio/mpeg': ['.mp3'],
'audio/wav': ['.wav'],
'audio/ogg': ['.ogg'],
'audio/flac': ['.flac'],
'audio/mp4': ['.m4a'],
'audio/aac': ['.aac'],
};
type UploadFormData = {
file: File | null;
title: string;
artist: string;
album: string;
genre: string;
};
const MAX_RETRY_ATTEMPTS = 3;
export function UploadModal({ open, onClose }: UploadModalProps) {
const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [isRetryable, setIsRetryable] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [success, setSuccess] = useState(false);
const isRateLimited = useIsRateLimited();
const queryClient = useQueryClient();
// TEMPORARY FIX: Replace useForm with useState to isolate react-hook-form initialization error
const [formData, setFormData] = useState<UploadFormData>({
file: null,
title: '',
artist: '',
album: '',
genre: '',
});
const [formErrors, setFormErrors] = useState<Partial<Record<keyof UploadFormData, string>>>({});
const reset = useCallback(() => {
setFormData({ file: null, title: '', artist: '', album: '', genre: '' });
setFormErrors({});
}, []);
const handleClose = useCallback(() => {
if (!isUploading) {
setFile(null);
setUploadProgress(0);
setError(null);
setErrorCode(null);
setIsRetryable(false);
setRetryCount(0);
setSuccess(false);
reset();
onClose();
}
}, [isUploading, reset, onClose]);
// Mock functions to replace useForm methods
const register = (name: keyof UploadFormData) => ({
value: formData[name] instanceof File ? '' : (formData[name] as string | undefined) || '',
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [name]: e.target.value }));
},
});
const setValue = (name: keyof UploadFormData, value: any) => {
setFormData(prev => ({ ...prev, [name]: value }));
};
const getValues = () => formData;
const handleSubmit = (onValid: (data: UploadFormData) => void, onInvalid?: (errors: any) => void) => {
return (e: React.FormEvent) => {
e.preventDefault();
// Simple validation
if (!formData.file) {
setFormErrors({ file: 'Veuillez sélectionner un fichier' });
onInvalid?.({ file: { message: 'Veuillez sélectionner un fichier' } });
return;
}
setFormErrors({});
onValid(formData);
};
};
const errors = formErrors;
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const selectedFile = acceptedFiles[0];
if (selectedFile) {
setFile(selectedFile);
setError(null);
setSuccess(false);
// Mettre à jour le formulaire avec setValue pour que react-hook-form connaisse le fichier
setValue('file', selectedFile);
// Pré-remplir le titre avec le nom du fichier (sans extension)
const fileNameWithoutExt = selectedFile.name.replace(/\.[^/.]+$/, '');
// Lecture à la demande avec getValues, pas de re-render
const currentTitle = getValues().title;
if (!currentTitle) {
setValue('title', fileNameWithoutExt);
}
}
},
[setValue, getValues],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: ACCEPTED_AUDIO_TYPES,
maxSize: MAX_FILE_SIZE,
multiple: false,
onError: (err) => {
setError(`Erreur lors de la sélection du fichier: ${err.message}`);
},
onDropRejected: (fileRejections) => {
const rejection = fileRejections[0];
if (rejection.errors[0]?.code === 'file-too-large') {
setError('Le fichier est trop volumineux (max 100 MB)');
} else if (rejection.errors[0]?.code === 'file-invalid-type') {
setError(
'Format de fichier non supporté. Formats acceptés: MP3, WAV, OGG, FLAC, M4A, AAC',
);
} else {
setError(
rejection.errors[0]?.message ||
'Erreur lors de la sélection du fichier',
);
}
},
});
const performUpload = useCallback(
async (data: UploadFormData, attemptNumber: number = 1) => {
if (!data.file) {
setError('Veuillez sélectionner un fichier');
setErrorCode(null);
setIsRetryable(false);
return;
}
setIsUploading(true);
setError(null);
setErrorCode(null);
setIsRetryable(false);
setSuccess(false);
setUploadProgress(0);
try {
const trackMetadata: TrackMetadata = {
title: data.title || data.file.name.replace(/\.[^/.]+$/, ''),
artist: data.artist,
album: data.album,
genre: data.genre,
is_public: false,
};
await tracksApi.create(data.file, trackMetadata, (progress) => {
setUploadProgress(progress);
});
setSuccess(true);
setUploadProgress(100);
setRetryCount(0);
// Invalider les queries pour rafraîchir la liste
queryClient.invalidateQueries({ queryKey: LIBRARY_KEYS.all });
queryClient.invalidateQueries({ queryKey: ['tracks'] });
// Fermer après 1.5 secondes
setTimeout(() => {
handleClose();
}, 1500);
} catch (err) {
let errorMessage = "Erreur lors de l'upload";
let errorCodeValue: string | null = null;
let retryable = false;
if (err instanceof Error) {
errorMessage = err.message;
// Détecter les erreurs réseau qui sont généralement retryables
const isNetworkError =
errorMessage.toLowerCase().includes('network') ||
errorMessage.toLowerCase().includes('réseau') ||
errorMessage.toLowerCase().includes('timeout') ||
errorMessage.toLowerCase().includes('econnaborted') ||
errorMessage.toLowerCase().includes('etimedout') ||
errorMessage.toLowerCase().includes('se connecter');
// Détecter les erreurs serveur qui peuvent être retryables
const isServerError =
errorMessage.toLowerCase().includes('serveur') ||
errorMessage.toLowerCase().includes('server') ||
errorMessage.toLowerCase().includes('500') ||
errorMessage.toLowerCase().includes('503') ||
errorMessage.toLowerCase().includes('502');
// Les erreurs de validation ne sont pas retryables
const isValidationError =
errorMessage.toLowerCase().includes('format') ||
errorMessage.toLowerCase().includes('taille') ||
errorMessage.toLowerCase().includes('invalide') ||
errorMessage.toLowerCase().includes('trop volumineux') ||
errorMessage.toLowerCase().includes('non supporté') ||
errorMessage.toLowerCase().includes('400') ||
errorMessage.toLowerCase().includes('413') ||
errorMessage.toLowerCase().includes('415');
if (isNetworkError) {
errorCodeValue = 'NETWORK';
retryable = attemptNumber < MAX_RETRY_ATTEMPTS;
} else if (isServerError) {
errorCodeValue = 'SERVER';
retryable = attemptNumber < MAX_RETRY_ATTEMPTS;
} else if (isValidationError) {
errorCodeValue = 'VALIDATION';
retryable = false;
}
}
setError(errorMessage);
setErrorCode(errorCodeValue);
setIsRetryable(retryable);
setUploadProgress(0);
setRetryCount(attemptNumber);
} finally {
setIsUploading(false);
}
},
[queryClient, handleClose],
);
const onSubmit = useCallback(
async (data: UploadFormData) => {
await performUpload(data, 1);
},
[performUpload],
);
const handleRetry = useCallback(() => {
const data = getValues();
const nextAttempt = retryCount + 1;
performUpload(data, nextAttempt);
}, [retryCount, getValues, performUpload]);
const handleRemoveFile = () => {
setFile(null);
setError(null);
setSuccess(false);
setUploadProgress(0);
setValue('file', null);
};
return (
<Dialog
open={open}
onClose={handleClose}
title="Uploader un fichier audio"
size="lg"
>
<form
id="upload-track-form"
onSubmit={handleSubmit(onSubmit, (errors) => {
logger.warn('Form validation errors:', { errors });
})}
>
<DialogBody>
<div className="space-y-6">
{/* Zone de Drag & Drop */}
{!file ? (
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-12 text-center cursor-pointer
transition-colors
${isDragActive ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
hover:border-primary hover:bg-primary/5
`}
>
<input {...getInputProps()} />
<FileAudio className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium mb-2">
{isDragActive
? 'Déposez le fichier ici'
: 'Glissez-déposez un fichier audio'}
</p>
<p className="text-sm text-muted-foreground mb-4">
ou cliquez pour sélectionner
</p>
<p className="text-xs text-muted-foreground">
Formats acceptés: MP3, WAV, OGG, FLAC, M4A, AAC (max 100 MB)
</p>
</div>
) : (
<div
className="border rounded-lg p-4"
data-testid="upload-file-display"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<FileAudio className="h-8 w-8 text-primary" />
<div>
<p className="font-medium" data-testid="upload-file-name">
{file.name}
</p>
<p className="text-sm text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleRemoveFile}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
{/* Progress Bar */}
{isUploading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Upload en cours...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} />
</div>
)}
{/* Messages d'erreur */}
{error && (
<Alert variant="destructive" data-testid="upload-error">
<div className="flex items-start gap-4">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div className="flex-1 space-y-2">
<div>
<p className="font-medium">{error}</p>
{errorCode && (
<p className="text-xs text-muted-foreground mt-1">
Code d'erreur: {errorCode}
</p>
)}
{retryCount > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Tentative {retryCount}/{MAX_RETRY_ATTEMPTS}
</p>
)}
</div>
{isRetryable && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRetry}
disabled={isUploading}
className="mt-2"
>
<RefreshCw className="h-4 w-4 mr-2" />
Réessayer
</Button>
)}
</div>
</div>
</Alert>
)}
{/* Message de succès */}
{success && (
<Alert className="bg-kodo-lime/10 border-kodo-lime text-kodo-lime">
<CheckCircle2 className="h-4 w-4" />
<span>Fichier uploadé avec succès !</span>
</Alert>
)}
{/* Formulaire de métadonnées */}
{file && !isUploading && !success && (
<div className="space-y-4 border-t pt-4">
<h3 className="font-medium">Métadonnées (optionnel)</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">Titre *</Label>
<Input
id="title"
{...register('title')}
placeholder="Titre du morceau"
/>
{errors.title && (
<p className="text-sm text-destructive">
{errors.title}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="artist">Artiste</Label>
<Input
id="artist"
{...register('artist')}
placeholder="Nom de l'artiste"
/>
</div>
<div className="space-y-2">
<Label htmlFor="album">Album</Label>
<Input
id="album"
{...register('album')}
placeholder="Nom de l'album"
/>
</div>
<div className="space-y-2">
<Label htmlFor="genre">Genre</Label>
<Input
id="genre"
{...register('genre')}
placeholder="Genre musical"
/>
</div>
</div>
</div>
)}
</div>
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isUploading}
type="button"
>
{success ? 'Fermer' : 'Annuler'}
</Button>
{!success && (
<Button
type="submit"
form="upload-track-form"
disabled={!file || isUploading || isRateLimited}
className="gap-2"
>
<Upload className="h-4 w-4" />
{isUploading ? 'Upload en cours...' : 'Uploader'}
</Button>
)}
</DialogFooter>
</form>
</Dialog>
);
}