feat: add multi-video upload support

- Add multi-file drag-and-drop to upload multiple videos at once
- Each video creates its own job using filename as title
- Single file upload preserves current UX with editable pre-filled title
- Multi-upload mode shows file list, individual progress bars, and summary
- Parallel uploads (max 3 concurrent) with error handling and retry
- Settings (language, outputs, TTS) apply to all jobs in batch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael 2025-12-22 20:01:48 -06:00
parent 44ef3ff741
commit 465eca8bab
6 changed files with 833 additions and 87 deletions

View file

@ -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 (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">
Selected Files ({files.length})
</span>
<button
type="button"
onClick={onClearAll}
disabled={disabled}
className="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Clear All
</button>
</div>
<div className="border border-gray-200 rounded-lg divide-y divide-gray-200">
{files.map((item) => (
<div
key={item.id}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{item.file.name}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(item.file.size)} &middot; Title: "{item.autoTitle}"
</p>
</div>
<button
type="button"
onClick={() => onRemove(item.id)}
disabled={disabled}
className="ml-4 text-gray-400 hover:text-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Remove ${item.file.name}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
<p className="mt-2 text-sm text-gray-500">
Total: {formatFileSize(totalSize)}
</p>
</div>
);
}

View file

@ -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<string, string[]>;
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({
</div>
{isDragActive ? (
<p className="text-blue-600 font-medium">Drop the video file here</p>
<p className="text-blue-600 font-medium">
Drop the video {multiple ? 'files' : 'file'} here
</p>
) : (
<div>
<p className="text-gray-600 font-medium">
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
</p>
<p className="text-sm text-gray-500 mt-2">
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' : ''}
</p>
</div>
)}

View file

@ -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 (
<div className="space-y-4">
<div className="text-sm font-medium text-gray-700">
Uploading Videos ({completedCount} of {totalCount} complete)
</div>
<div className="border border-gray-200 rounded-lg divide-y divide-gray-200">
{uploads.map((upload) => (
<div key={upload.id} className="px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<StatusIcon status={upload.status} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 truncate">
{upload.filename}
</p>
{upload.status === 'success' && upload.jobId && (
<p className="text-xs text-gray-500">
Job: "{upload.autoTitle}"
</p>
)}
{upload.status === 'error' && upload.error && (
<p className="text-xs text-red-600">{upload.error}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
{upload.status === 'pending' && (
<span className="text-sm text-gray-500">Pending</span>
)}
{upload.status === 'uploading' && (
<span className="text-sm text-blue-600">{upload.progress}%</span>
)}
{upload.status === 'success' && upload.jobId && (
<Link
to={`/jobs/${upload.jobId}`}
className="text-sm text-blue-600 hover:text-blue-800"
>
View Job
</Link>
)}
{upload.status === 'error' && onRetry && (
<button
type="button"
onClick={() => onRetry(upload.id)}
className="text-sm text-blue-600 hover:text-blue-800"
>
Retry
</button>
)}
</div>
</div>
{upload.status === 'uploading' && (
<div className="mt-2 w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${upload.progress}%` }}
/>
</div>
)}
</div>
))}
</div>
</div>
);
}
function StatusIcon({ status }: { status: UploadStatus }) {
switch (status) {
case 'pending':
return (
<div className="w-5 h-5 rounded-full border-2 border-gray-300" />
);
case 'uploading':
return (
<svg className="w-5 h-5 text-blue-600 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
);
case 'success':
return (
<div className="w-5 h-5 rounded-full bg-green-100 flex items-center justify-center">
<svg className="w-3 h-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
);
case 'error':
return (
<div className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center">
<svg className="w-3 h-3 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
);
}
}

View file

@ -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<UploadProgressItem[]>;
retryFailed: (settings: SharedJobSettings) => Promise<void>;
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<FileListItem[]>([]);
const [uploads, setUploads] = useState<UploadProgressItem[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isComplete, setIsComplete] = useState(false);
// Store settings ref for retry
const settingsRef = useRef<SharedJobSettings | null>(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<UploadProgressItem>) => {
setUploads(prev => prev.map(u => u.id === id ? { ...u, ...update } : u));
}, []);
const uploadSingleFile = useCallback(async (
item: FileListItem,
settings: SharedJobSettings
): Promise<UploadProgressItem> => {
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<UploadProgressItem[]> => {
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<void>[] = [];
// 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<void> => {
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<void>[] = [];
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';
}

View file

@ -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`;
}

View file

@ -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<typeof jobSchema>;
export function NewJob() {
// Single upload state (for 1 file)
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [createdJob, setCreatedJob] = useState<string | null>(null);
// Multi upload state (for 2+ files)
const multiUpload = useMultiUpload({ maxConcurrent: 3 });
// Shared state
const [showVoiceSettings, setShowVoiceSettings] = useState(false);
const [ttsPreferences, setTtsPreferences] = useState<TTSPreferences>({
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<JobFormData>({
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 (
<div className="container mx-auto px-4 py-8 max-w-2xl">
@ -180,11 +274,7 @@ export function NewJob() {
</button>
<div>
<button
onClick={() => {
setCreatedJob(null);
setSelectedFile(null);
setUploadProgress(0);
}}
onClick={handleReset}
className="text-gray-600 hover:text-gray-800"
>
Create Another Job
@ -196,58 +286,211 @@ export function NewJob() {
);
}
// Multi-upload complete state
if (multiUpload.isComplete) {
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="text-center py-8">
<div className="mx-auto flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Upload Complete!</h1>
<p className="text-gray-600 mb-6">
{multiUpload.successCount} of {multiUpload.totalFiles} videos uploaded successfully
</p>
</div>
{/* Success list */}
{multiUpload.successCount > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-gray-700 mb-2">Successful ({multiUpload.successCount})</h2>
<div className="border border-gray-200 rounded-lg divide-y divide-gray-200">
{multiUpload.uploads
.filter(u => u.status === 'success')
.map(upload => (
<div key={upload.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-green-100 flex items-center justify-center">
<svg className="w-3 h-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-sm text-gray-900">{upload.autoTitle}</span>
</div>
<Link
to={`/jobs/${upload.jobId}`}
className="text-sm text-blue-600 hover:text-blue-800"
>
View Job
</Link>
</div>
))}
</div>
</div>
)}
{/* Failed list */}
{multiUpload.failedCount > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-gray-700 mb-2">Failed ({multiUpload.failedCount})</h2>
<div className="border border-red-200 rounded-lg divide-y divide-red-200 bg-red-50">
{multiUpload.uploads
.filter(u => u.status === 'error')
.map(upload => (
<div key={upload.id} className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center">
<svg className="w-3 h-3 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<span className="text-sm text-gray-900">{upload.filename}</span>
</div>
<p className="text-xs text-red-600 ml-7 mt-1">{upload.error}</p>
</div>
))}
</div>
<button
type="button"
onClick={handleRetryFailed}
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
>
Retry Failed Uploads
</button>
</div>
)}
<div className="flex gap-4 justify-center pt-4">
<button
onClick={() => navigate('/jobs')}
className="px-6 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
View All Jobs
</button>
<button
onClick={handleReset}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Upload More Videos
</button>
</div>
</div>
);
}
// Multi-upload in progress state
if (multiUpload.isUploading) {
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Uploading Videos</h1>
<UploadProgressList uploads={multiUpload.uploads} />
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Create New Job</h1>
{/* Progress Bar */}
{createJobMutation.isPending && (
{/* Single file progress bar */}
{createJobMutation.isPending && !isMultiMode && (
<div className="mb-6 bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Uploading...</span>
<span className="text-sm text-gray-500">{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Video Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Video File
Video {isMultiMode ? 'Files' : 'File'}
</label>
<UploadDropzone
onFileSelect={setSelectedFile}
disabled={createJobMutation.isPending}
onFilesSelect={handleFilesSelect}
multiple={true}
disabled={isUploading}
/>
{selectedFile && (
<p className="mt-2 text-sm text-gray-600">
Selected: {selectedFile.name} ({Math.round(selectedFile.size / (1024 * 1024))}MB)
</p>
{/* Single file display */}
{selectedFile && !isMultiMode && (
<div className="mt-2 flex items-center justify-between text-sm text-gray-600">
<span>Selected: {selectedFile.name} ({Math.round(selectedFile.size / (1024 * 1024))}MB)</span>
<button
type="button"
onClick={handleRemoveSingleFile}
className="text-gray-400 hover:text-red-500"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Multi-file list */}
{isMultiMode && (
<MultiUploadFileList
files={multiUpload.files}
onRemove={(id) => {
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}
/>
)}
</div>
{/* Job Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Job Title
</label>
<input
type="text"
{...register('title')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter a descriptive title for this video"
/>
{errors.title && (
<p className="mt-1 text-sm text-red-600">{errors.title.message}</p>
)}
</div>
{/* Job Title - only shown in single file mode */}
{!isMultiMode && selectedFile && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Job Title
</label>
<input
type="text"
{...register('title')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter a descriptive title for this video"
/>
{errors.title && (
<p className="mt-1 text-sm text-red-600">{errors.title.message}</p>
)}
</div>
)}
{/* Multi-mode info */}
{isMultiMode && (
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Multi-upload mode:</strong> Job titles will be automatically generated from file names.
Settings below will apply to all {multiUpload.files.length} videos.
</p>
</div>
)}
{/* Original Video Language */}
<div>
@ -367,7 +610,7 @@ export function NewJob() {
selectedLanguages={['en', ...languages]}
preferences={ttsPreferences}
onChange={setTtsPreferences}
disabled={createJobMutation.isPending}
disabled={isUploading}
/>
</div>
)}
@ -418,42 +661,20 @@ export function NewJob() {
</div>
</div>
{/* Transcreation Options - HIDDEN: Not fully implemented */}
{/* {languages.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Transcreation (Cultural Adaptation)
</label>
<p className="text-sm text-gray-500 mb-2">
Select languages that need cultural adaptation instead of direct translation
</p>
<div className="space-y-2">
{languages.map(lang => (
<label key={lang} className="flex items-center">
<input
type="checkbox"
checked={transcreation.includes(lang)}
onChange={() => toggleTranscreation(lang)}
className="mr-2"
/>
<span>Transcreate to {lang}</span>
</label>
))}
</div>
</div>
)} */}
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={!selectedFile || createJobMutation.isPending}
disabled={totalSelectedFiles === 0 || isUploading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createJobMutation.isPending ? 'Creating Job...' : 'Create Job'}
{isUploading
? (isMultiMode ? 'Uploading Videos...' : 'Creating Job...')
: (isMultiMode ? `Upload ${multiUpload.files.length} Videos` : 'Create Job')
}
</button>
</div>
</form>
</div>
);
}
}