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:
parent
44ef3ff741
commit
465eca8bab
6 changed files with 833 additions and 87 deletions
74
frontend/src/components/MultiUploadFileList.tsx
Normal file
74
frontend/src/components/MultiUploadFileList.tsx
Normal 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)} · 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
123
frontend/src/components/UploadProgressList.tsx
Normal file
123
frontend/src/components/UploadProgressList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
290
frontend/src/hooks/useMultiUpload.ts
Normal file
290
frontend/src/hooks/useMultiUpload.ts
Normal 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';
|
||||
}
|
||||
31
frontend/src/lib/fileUtils.ts
Normal file
31
frontend/src/lib/fileUtils.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue