- Add upload-modal/ with useUploadModal, constants, and presentational components: Dropzone, FileDisplay, Progress, ErrorAlert, MetadataForm - Re-export from UploadModal.tsx for backward compatibility Co-authored-by: Cursor <cursoragent@cursor.com>
231 lines
7.2 KiB
TypeScript
231 lines
7.2 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { useDropzone } from 'react-dropzone';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { tracksApi, type TrackMetadata } from '@/services/api/tracks';
|
|
import { LIBRARY_KEYS } from '@/features/library/hooks/useMyTracks';
|
|
import { logger } from '@/utils/logger';
|
|
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
|
|
import {
|
|
ACCEPTED_AUDIO_TYPES,
|
|
MAX_FILE_SIZE,
|
|
MAX_RETRY_ATTEMPTS,
|
|
type UploadFormData,
|
|
} from './constants';
|
|
|
|
export interface UseUploadModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function useUploadModal({ onClose }: UseUploadModalProps) {
|
|
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 [formData, setFormData] = useState<UploadFormData>({
|
|
file: null,
|
|
title: '',
|
|
artist: '',
|
|
album: '',
|
|
genre: '',
|
|
});
|
|
const [formErrors, setFormErrors] = useState<Partial<Record<keyof UploadFormData, string>>>({});
|
|
const isRateLimited = useIsRateLimited();
|
|
const queryClient = useQueryClient();
|
|
|
|
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]);
|
|
|
|
const setValue = useCallback((name: keyof UploadFormData, value: UploadFormData[keyof UploadFormData]) => {
|
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
}, []);
|
|
const getValues = useCallback(() => formData, [formData]);
|
|
|
|
const performUpload = useCallback(
|
|
async (data: UploadFormData, attemptNumber = 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);
|
|
queryClient.invalidateQueries({ queryKey: LIBRARY_KEYS.all });
|
|
queryClient.invalidateQueries({ queryKey: ['tracks'] });
|
|
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;
|
|
const isNetworkError =
|
|
/network|réseau|timeout|econnaborted|etimedout|se connecter/i.test(errorMessage);
|
|
const isServerError =
|
|
/serveur|server|500|503|502/i.test(errorMessage);
|
|
const isValidationError =
|
|
/format|taille|invalide|trop volumineux|non supporté|400|413|415/i.test(errorMessage);
|
|
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 onDrop = useCallback(
|
|
(acceptedFiles: File[]) => {
|
|
const selectedFile = acceptedFiles[0];
|
|
if (selectedFile) {
|
|
setFile(selectedFile);
|
|
setError(null);
|
|
setSuccess(false);
|
|
setValue('file', selectedFile);
|
|
const fileNameWithoutExt = selectedFile.name.replace(/\.[^/.]+$/, '');
|
|
if (!formData.title) setValue('title', fileNameWithoutExt);
|
|
}
|
|
},
|
|
[setValue, formData.title],
|
|
);
|
|
|
|
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 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 handleSubmit = useCallback(
|
|
(onValid: (data: UploadFormData) => void, onInvalid?: (errors: unknown) => void) =>
|
|
(e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!formData.file) {
|
|
setFormErrors({ file: 'Veuillez sélectionner un fichier' });
|
|
onInvalid?.({ file: { message: 'Veuillez sélectionner un fichier' } });
|
|
return;
|
|
}
|
|
setFormErrors({});
|
|
onValid(formData);
|
|
},
|
|
[formData],
|
|
);
|
|
|
|
const onSubmit = useCallback(
|
|
async (data: UploadFormData) => {
|
|
await performUpload(data, 1);
|
|
},
|
|
[performUpload],
|
|
);
|
|
|
|
const handleRetry = useCallback(() => {
|
|
performUpload(getValues(), retryCount + 1);
|
|
}, [retryCount, getValues, performUpload]);
|
|
|
|
const handleRemoveFile = useCallback(() => {
|
|
setFile(null);
|
|
setError(null);
|
|
setSuccess(false);
|
|
setUploadProgress(0);
|
|
setValue('file', null);
|
|
}, [setValue]);
|
|
|
|
return {
|
|
file,
|
|
uploadProgress,
|
|
isUploading,
|
|
error,
|
|
errorCode,
|
|
isRetryable,
|
|
retryCount,
|
|
success,
|
|
formData,
|
|
formErrors: formErrors as Partial<Record<keyof UploadFormData, string>>,
|
|
register,
|
|
handleSubmit,
|
|
onSubmit,
|
|
getValues,
|
|
getRootProps,
|
|
getInputProps,
|
|
isDragActive,
|
|
handleClose,
|
|
handleRetry,
|
|
handleRemoveFile,
|
|
isRateLimited,
|
|
};
|
|
}
|