diff --git a/frontend/src/components/MultiUploadFileList.tsx b/frontend/src/components/MultiUploadFileList.tsx new file mode 100644 index 0000000..0f616aa --- /dev/null +++ b/frontend/src/components/MultiUploadFileList.tsx @@ -0,0 +1,74 @@ +import { formatFileSize } from '../lib/fileUtils'; + +export interface FileListItem { + id: string; + file: File; + autoTitle: string; +} + +interface MultiUploadFileListProps { + files: FileListItem[]; + onRemove: (id: string) => void; + onClearAll: () => void; + disabled?: boolean; +} + +export function MultiUploadFileList({ + files, + onRemove, + onClearAll, + disabled = false +}: MultiUploadFileListProps) { + const totalSize = files.reduce((sum, f) => sum + f.file.size, 0); + + return ( +
+
+ + Selected Files ({files.length}) + + +
+ +
+ {files.map((item) => ( +
+
+

+ {item.file.name} +

+

+ {formatFileSize(item.file.size)} · Title: "{item.autoTitle}" +

+
+ +
+ ))} +
+ +

+ Total: {formatFileSize(totalSize)} +

+
+ ); +} diff --git a/frontend/src/components/UploadDropzone/UploadDropzone.tsx b/frontend/src/components/UploadDropzone/UploadDropzone.tsx index 34b94cc..7c27ff8 100644 --- a/frontend/src/components/UploadDropzone/UploadDropzone.tsx +++ b/frontend/src/components/UploadDropzone/UploadDropzone.tsx @@ -2,14 +2,16 @@ import { useState, useCallback } from 'react'; import { useDropzone, type FileRejection } from 'react-dropzone'; interface UploadDropzoneProps { - onFileSelect: (file: File) => void; + onFilesSelect: (files: File[]) => void; + multiple?: boolean; accept?: Record; maxSize?: number; disabled?: boolean; } export function UploadDropzone({ - onFileSelect, + onFilesSelect, + multiple = false, accept = { 'video/*': ['.mp4', '.mov', '.avi', '.mkv'] }, maxSize = 1024 * 1024 * 1024, // 1GB disabled = false @@ -18,7 +20,7 @@ export function UploadDropzone({ const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => { setError(null); - + if (rejectedFiles.length > 0) { const rejection = rejectedFiles[0]; if (rejection.errors.some((e) => e.code === 'file-too-large')) { @@ -28,19 +30,22 @@ export function UploadDropzone({ } else { setError('File upload failed. Please try again.'); } - return; + // Still process any accepted files even if some were rejected + if (acceptedFiles.length === 0) { + return; + } } if (acceptedFiles.length > 0) { - onFileSelect(acceptedFiles[0]); + onFilesSelect(acceptedFiles); } - }, [onFileSelect, maxSize]); + }, [onFilesSelect, maxSize]); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept, maxSize, - multiple: false, + multiple, disabled }); @@ -69,14 +74,16 @@ export function UploadDropzone({ {isDragActive ? ( -

Drop the video file here

+

+ Drop the video {multiple ? 'files' : 'file'} here +

) : (

- Drag and drop a video file here, or click to select + Drag and drop video {multiple ? 'files' : 'a video file'} here, or click to select

- Supports MP4, MOV, AVI, MKV up to {Math.round(maxSize / (1024 * 1024))}MB + Supports MP4, MOV, AVI, MKV up to {Math.round(maxSize / (1024 * 1024))}MB{multiple ? ' each' : ''}

)} diff --git a/frontend/src/components/UploadProgressList.tsx b/frontend/src/components/UploadProgressList.tsx new file mode 100644 index 0000000..5d32070 --- /dev/null +++ b/frontend/src/components/UploadProgressList.tsx @@ -0,0 +1,123 @@ +import { Link } from 'react-router-dom'; + +export type UploadStatus = 'pending' | 'uploading' | 'success' | 'error'; + +export interface UploadProgressItem { + id: string; + filename: string; + autoTitle: string; + status: UploadStatus; + progress: number; + error?: string; + jobId?: string; +} + +interface UploadProgressListProps { + uploads: UploadProgressItem[]; + onRetry?: (id: string) => void; +} + +export function UploadProgressList({ uploads, onRetry }: UploadProgressListProps) { + const completedCount = uploads.filter(u => u.status === 'success').length; + const totalCount = uploads.length; + + return ( +
+
+ Uploading Videos ({completedCount} of {totalCount} complete) +
+ +
+ {uploads.map((upload) => ( +
+
+
+ +
+

+ {upload.filename} +

+ {upload.status === 'success' && upload.jobId && ( +

+ Job: "{upload.autoTitle}" +

+ )} + {upload.status === 'error' && upload.error && ( +

{upload.error}

+ )} +
+
+ +
+ {upload.status === 'pending' && ( + Pending + )} + {upload.status === 'uploading' && ( + {upload.progress}% + )} + {upload.status === 'success' && upload.jobId && ( + + View Job + + )} + {upload.status === 'error' && onRetry && ( + + )} +
+
+ + {upload.status === 'uploading' && ( +
+
+
+ )} +
+ ))} +
+
+ ); +} + +function StatusIcon({ status }: { status: UploadStatus }) { + switch (status) { + case 'pending': + return ( +
+ ); + case 'uploading': + return ( + + + + + ); + case 'success': + return ( +
+ + + +
+ ); + case 'error': + return ( +
+ + + +
+ ); + } +} diff --git a/frontend/src/hooks/useMultiUpload.ts b/frontend/src/hooks/useMultiUpload.ts new file mode 100644 index 0000000..2c8e826 --- /dev/null +++ b/frontend/src/hooks/useMultiUpload.ts @@ -0,0 +1,290 @@ +import { useState, useCallback, useRef } from 'react'; +import { apiClient } from '../lib/api'; +import { generateFileId, generateTitleFromFilename } from '../lib/fileUtils'; +import type { RequestedOutputs } from '../types/api'; +import type { UploadStatus, UploadProgressItem } from '../components/UploadProgressList'; + +export interface FileListItem { + id: string; + file: File; + autoTitle: string; +} + +export interface SharedJobSettings { + sourceIsEnglish: boolean; + languageHint?: string; + requestedOutputs: RequestedOutputs; +} + +interface UseMultiUploadOptions { + maxConcurrent?: number; + onJobCreated?: (jobId: string, filename: string) => void; + onAllComplete?: (results: UploadProgressItem[]) => void; +} + +interface UseMultiUploadReturn { + // State + files: FileListItem[]; + uploads: UploadProgressItem[]; + isUploading: boolean; + isComplete: boolean; + + // Actions + addFiles: (newFiles: File[]) => void; + removeFile: (id: string) => void; + clearFiles: () => void; + startUpload: (settings: SharedJobSettings) => Promise; + retryFailed: (settings: SharedJobSettings) => Promise; + reset: () => void; + + // Computed + totalFiles: number; + completedCount: number; + failedCount: number; + successCount: number; +} + +export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUploadReturn { + const { maxConcurrent = 3, onJobCreated, onAllComplete } = options; + + const [files, setFiles] = useState([]); + const [uploads, setUploads] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [isComplete, setIsComplete] = useState(false); + + // Store settings ref for retry + const settingsRef = useRef(null); + + const addFiles = useCallback((newFiles: File[]) => { + const items: FileListItem[] = newFiles.map(file => ({ + id: generateFileId(), + file, + autoTitle: generateTitleFromFilename(file.name), + })); + setFiles(prev => [...prev, ...items]); + // Reset complete state when adding new files + setIsComplete(false); + setUploads([]); + }, []); + + const removeFile = useCallback((id: string) => { + setFiles(prev => prev.filter(f => f.id !== id)); + }, []); + + const clearFiles = useCallback(() => { + setFiles([]); + setUploads([]); + setIsComplete(false); + }, []); + + const reset = useCallback(() => { + setFiles([]); + setUploads([]); + setIsUploading(false); + setIsComplete(false); + settingsRef.current = null; + }, []); + + const updateUploadProgress = useCallback((id: string, update: Partial) => { + setUploads(prev => prev.map(u => u.id === id ? { ...u, ...update } : u)); + }, []); + + const uploadSingleFile = useCallback(async ( + item: FileListItem, + settings: SharedJobSettings + ): Promise => { + const uploadItem: UploadProgressItem = { + id: item.id, + filename: item.file.name, + autoTitle: item.autoTitle, + status: 'uploading', + progress: 0, + }; + + updateUploadProgress(item.id, { status: 'uploading', progress: 0 }); + + try { + const job = await apiClient.createJob( + { + title: item.autoTitle, + source_is_english: settings.sourceIsEnglish, + language_hint: settings.languageHint, + requested_outputs: settings.requestedOutputs, + }, + item.file, + (progressEvent) => { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total); + updateUploadProgress(item.id, { progress: percent }); + } + ); + + const result: UploadProgressItem = { + ...uploadItem, + status: 'success', + progress: 100, + jobId: job.id, + }; + updateUploadProgress(item.id, result); + onJobCreated?.(job.id, item.file.name); + return result; + } catch (error) { + const errorMessage = extractErrorMessage(error); + const result: UploadProgressItem = { + ...uploadItem, + status: 'error', + error: errorMessage, + }; + updateUploadProgress(item.id, result); + return result; + } + }, [updateUploadProgress, onJobCreated]); + + const startUpload = useCallback(async (settings: SharedJobSettings): Promise => { + if (files.length === 0) return []; + + settingsRef.current = settings; + setIsUploading(true); + setIsComplete(false); + + // Initialize upload items + const initialUploads: UploadProgressItem[] = files.map(f => ({ + id: f.id, + filename: f.file.name, + autoTitle: f.autoTitle, + status: 'pending' as UploadStatus, + progress: 0, + })); + setUploads(initialUploads); + + const results: UploadProgressItem[] = []; + const queue = [...files]; + const active: Promise[] = []; + + // Process queue with concurrency limit + while (queue.length > 0 || active.length > 0) { + // Fill up to maxConcurrent + while (active.length < maxConcurrent && queue.length > 0) { + const item = queue.shift()!; + const promise = uploadSingleFile(item, settings) + .then(result => { + results.push(result); + }) + .finally(() => { + const index = active.indexOf(promise); + if (index > -1) active.splice(index, 1); + }); + active.push(promise); + } + + // Wait for at least one to complete + if (active.length > 0) { + await Promise.race(active); + } + } + + setIsUploading(false); + setIsComplete(true); + onAllComplete?.(results); + return results; + }, [files, maxConcurrent, uploadSingleFile, onAllComplete]); + + const retryFailed = useCallback(async (settings: SharedJobSettings): Promise => { + const failedItems = uploads + .filter(u => u.status === 'error') + .map(u => files.find(f => f.id === u.id)) + .filter((f): f is FileListItem => f !== undefined); + + if (failedItems.length === 0) return; + + settingsRef.current = settings; + setIsUploading(true); + setIsComplete(false); + + // Reset failed items to pending + setUploads(prev => prev.map(u => + u.status === 'error' ? { ...u, status: 'pending' as UploadStatus, error: undefined, progress: 0 } : u + )); + + const queue = [...failedItems]; + const active: Promise[] = []; + + while (queue.length > 0 || active.length > 0) { + while (active.length < maxConcurrent && queue.length > 0) { + const item = queue.shift()!; + const promise = uploadSingleFile(item, settings) + .finally(() => { + const index = active.indexOf(promise); + if (index > -1) active.splice(index, 1); + }); + active.push(promise); + } + + if (active.length > 0) { + await Promise.race(active); + } + } + + setIsUploading(false); + setIsComplete(true); + }, [files, uploads, maxConcurrent, uploadSingleFile]); + + // Computed values + const totalFiles = files.length; + const completedCount = uploads.filter(u => u.status === 'success' || u.status === 'error').length; + const successCount = uploads.filter(u => u.status === 'success').length; + const failedCount = uploads.filter(u => u.status === 'error').length; + + return { + files, + uploads, + isUploading, + isComplete, + addFiles, + removeFile, + clearFiles, + startUpload, + retryFailed, + reset, + totalFiles, + completedCount, + failedCount, + successCount, + }; +} + +interface AxiosErrorLike { + code?: string; + message?: string; + response?: { + status?: number; + data?: { detail?: string }; + }; +} + +function extractErrorMessage(error: unknown): string { + const err = error as AxiosErrorLike; + + if (err.code === 'ERR_NETWORK') { + return 'Network error - cannot reach server'; + } + if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) { + return 'Upload timeout - file may be too large'; + } + if (err.response?.status === 413) { + return 'File too large (max 500MB)'; + } + if (err.response?.status === 400) { + return err.response?.data?.detail || 'Invalid file or request'; + } + if (err.response?.status === 401) { + return 'Session expired - please log in again'; + } + if (err.response?.status === 403) { + return 'Permission denied'; + } + if (err.response?.status === 500) { + return 'Server error - please try again'; + } + + return err.message || 'Unknown error'; +} diff --git a/frontend/src/lib/fileUtils.ts b/frontend/src/lib/fileUtils.ts new file mode 100644 index 0000000..4990a91 --- /dev/null +++ b/frontend/src/lib/fileUtils.ts @@ -0,0 +1,31 @@ +/** + * Extract title from filename by removing extension. + * Handles multiple dots in filename (e.g., "video.v2.mp4" -> "video.v2") + */ +export function generateTitleFromFilename(filename: string): string { + const lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex > 0) { + return filename.substring(0, lastDotIndex); + } + return filename; +} + +/** + * Generate unique ID for file tracking (not MongoDB ID) + */ +export function generateFileId(): string { + return crypto.randomUUID(); +} + +/** + * Format file size for display + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${Math.round(bytes / 1024)} KB`; + } + return `${Math.round(bytes / (1024 * 1024))} MB`; +} diff --git a/frontend/src/routes/jobs/NewJob.tsx b/frontend/src/routes/jobs/NewJob.tsx index 7ef48a4..23a7642 100644 --- a/frontend/src/routes/jobs/NewJob.tsx +++ b/frontend/src/routes/jobs/NewJob.tsx @@ -1,12 +1,16 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Link } from 'react-router-dom'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { UploadDropzone } from '../../components/UploadDropzone/UploadDropzone'; import { VoiceSelector } from '../../components/VoiceSelector'; +import { MultiUploadFileList } from '../../components/MultiUploadFileList'; +import { UploadProgressList } from '../../components/UploadProgressList'; import { useCreateJob } from '../../hooks/useJob'; +import { useMultiUpload } from '../../hooks/useMultiUpload'; import { useToastContext } from '../../contexts/ToastContext'; +import { generateTitleFromFilename } from '../../lib/fileUtils'; import type { JobCreateRequest, TTSPreferences } from '../../types/api'; const jobSchema = z.object({ @@ -23,9 +27,15 @@ const jobSchema = z.object({ type JobFormData = z.infer; export function NewJob() { + // Single upload state (for 1 file) const [selectedFile, setSelectedFile] = useState(null); const [uploadProgress, setUploadProgress] = useState(0); const [createdJob, setCreatedJob] = useState(null); + + // Multi upload state (for 2+ files) + const multiUpload = useMultiUpload({ maxConcurrent: 3 }); + + // Shared state const [showVoiceSettings, setShowVoiceSettings] = useState(false); const [ttsPreferences, setTtsPreferences] = useState({ provider: 'gemini', @@ -36,10 +46,16 @@ export function NewJob() { style_preset: 'neutral', custom_style_prompt: undefined }); + const navigate = useNavigate(); const toast = useToastContext(); const createJobMutation = useCreateJob(); + // Determine mode based on total selected files + const totalSelectedFiles = selectedFile ? 1 : multiUpload.files.length; + const isMultiMode = totalSelectedFiles >= 2; + const isUploading = createJobMutation.isPending || multiUpload.isUploading; + const { register, handleSubmit, @@ -49,6 +65,7 @@ export function NewJob() { } = useForm({ resolver: zodResolver(jobSchema), defaultValues: { + title: '', sourceIsEnglish: true, languageHint: '', captions_vtt: true, @@ -64,7 +81,32 @@ export function NewJob() { const sourceIsEnglish = watch('sourceIsEnglish'); const audioDescriptionMp3 = watch('audio_description_mp3'); - const onSubmit = async (data: JobFormData) => { + // Handle file selection from dropzone + const handleFilesSelect = (files: File[]) => { + if (files.length === 1 && multiUpload.files.length === 0) { + // Single file mode + setSelectedFile(files[0]); + // Pre-fill title with filename + setValue('title', generateTitleFromFilename(files[0].name)); + } else { + // Multi-file mode - clear single file and add to multi + if (selectedFile) { + // Move existing single file to multi-upload + multiUpload.addFiles([selectedFile]); + setSelectedFile(null); + } + multiUpload.addFiles(files); + } + }; + + // Handle removing single file + const handleRemoveSingleFile = () => { + setSelectedFile(null); + setValue('title', ''); + }; + + // Single file submit handler + const onSubmitSingle = async (data: JobFormData) => { if (!selectedFile) { toast.toastOnly.error('Please select a video file'); return; @@ -86,29 +128,29 @@ export function NewJob() { try { setUploadProgress(0); - - const job = await createJobMutation.mutateAsync({ - data: jobData, + + const job = await createJobMutation.mutateAsync({ + data: jobData, file: selectedFile, onUploadProgress: (progressEvent) => { const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); setUploadProgress(percentCompleted); } }); - + setCreatedJob(job.id); - - // Auto-redirect after 3 seconds or when user clicks + + // Auto-redirect after 3 seconds setTimeout(() => { - if (createdJob) { - navigate(`/jobs/${job.id}`); - } + navigate(`/jobs/${job.id}`); }, 3000); } catch (error) { console.error('Job creation failed:', error); - - // Provide specific error messages based on error type - const err = error as any; + const err = error as { + code?: string; + message?: string; + response?: { status?: number; data?: { detail?: string } }; + }; let errorMessage = 'Failed to create job. '; if (err.code === 'ERR_NETWORK') { @@ -141,6 +183,36 @@ export function NewJob() { } }; + // Multi-file submit handler + const onSubmitMulti = async (data: JobFormData) => { + if (multiUpload.files.length === 0) { + toast.toastOnly.error('Please select video files'); + return; + } + + await multiUpload.startUpload({ + sourceIsEnglish: data.sourceIsEnglish, + languageHint: data.sourceIsEnglish ? undefined : data.languageHint || undefined, + requestedOutputs: { + captions_vtt: data.captions_vtt, + audio_description_vtt: data.audio_description_vtt, + audio_description_mp3: data.audio_description_mp3, + languages: data.languages, + transcreation: data.transcreation, + tts_preferences: data.audio_description_mp3 ? ttsPreferences : undefined, + } + }); + }; + + // Form submit router + const onSubmit = async (data: JobFormData) => { + if (isMultiMode) { + await onSubmitMulti(data); + } else { + await onSubmitSingle(data); + } + }; + const addLanguage = (lang: string) => { if (!languages.includes(lang)) { setValue('languages', [...languages, lang]); @@ -152,9 +224,31 @@ export function NewJob() { setValue('transcreation', transcreation.filter(l => l !== lang)); }; - // Removed toggleTranscreation - not currently used + const handleReset = () => { + setCreatedJob(null); + setSelectedFile(null); + setUploadProgress(0); + multiUpload.reset(); + setValue('title', ''); + }; - // Success state + const handleRetryFailed = async () => { + const data = watch(); + await multiUpload.retryFailed({ + sourceIsEnglish: data.sourceIsEnglish, + languageHint: data.sourceIsEnglish ? undefined : data.languageHint || undefined, + requestedOutputs: { + captions_vtt: data.captions_vtt, + audio_description_vtt: data.audio_description_vtt, + audio_description_mp3: data.audio_description_mp3, + languages: data.languages, + transcreation: data.transcreation, + tts_preferences: data.audio_description_mp3 ? ttsPreferences : undefined, + } + }); + }; + + // Single file success state if (createdJob) { return (
@@ -180,11 +274,7 @@ export function NewJob() {
+
+ )} + +
+ + +
+
+ ); + } + + // Multi-upload in progress state + if (multiUpload.isUploading) { + return ( +
+

Uploading Videos

+ +
+ ); + } + return (

Create New Job

- - {/* Progress Bar */} - {createJobMutation.isPending && ( + + {/* Single file progress bar */} + {createJobMutation.isPending && !isMultiMode && (
Uploading... {uploadProgress}%
-
)} - +
{/* Video Upload */}
- {selectedFile && ( -

- Selected: {selectedFile.name} ({Math.round(selectedFile.size / (1024 * 1024))}MB) -

+ + {/* Single file display */} + {selectedFile && !isMultiMode && ( +
+ Selected: {selectedFile.name} ({Math.round(selectedFile.size / (1024 * 1024))}MB) + +
+ )} + + {/* Multi-file list */} + {isMultiMode && ( + { + multiUpload.removeFile(id); + // If only one file left after removal, switch back to single mode + if (multiUpload.files.length === 2) { + const remainingFile = multiUpload.files.find(f => f.id !== id); + if (remainingFile) { + setSelectedFile(remainingFile.file); + setValue('title', remainingFile.autoTitle); + multiUpload.clearFiles(); + } + } + }} + onClearAll={() => { + multiUpload.clearFiles(); + setSelectedFile(null); + setValue('title', ''); + }} + disabled={isUploading} + /> )}
- {/* Job Title */} -
- - - {errors.title && ( -

{errors.title.message}

- )} -
+ {/* Job Title - only shown in single file mode */} + {!isMultiMode && selectedFile && ( +
+ + + {errors.title && ( +

{errors.title.message}

+ )} +
+ )} + + {/* Multi-mode info */} + {isMultiMode && ( +
+

+ Multi-upload mode: Job titles will be automatically generated from file names. + Settings below will apply to all {multiUpload.files.length} videos. +

+
+ )} {/* Original Video Language */}
@@ -367,7 +610,7 @@ export function NewJob() { selectedLanguages={['en', ...languages]} preferences={ttsPreferences} onChange={setTtsPreferences} - disabled={createJobMutation.isPending} + disabled={isUploading} />
)} @@ -418,42 +661,20 @@ export function NewJob() {
- {/* Transcreation Options - HIDDEN: Not fully implemented */} - {/* {languages.length > 0 && ( -
- -

- Select languages that need cultural adaptation instead of direct translation -

-
- {languages.map(lang => ( - - ))} -
-
- )} */} - {/* Submit Button */}
); -} \ No newline at end of file +}