feat: add bulk download action for approved/completed jobs

Add "Download All Files" option to the bulk actions menu on the All Jobs page.
When selected, downloads all assets (source video, VTTs, MP3s for all languages)
from jobs in approved_english, approved_source, or completed status.

- Shows confirmation modal with eligible/ineligible job counts
- Downloads files sequentially with progress indicator
- Skips jobs not in approved/completed status with warning

🤖 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 19:36:25 -06:00
parent dad7ea09df
commit 44ef3ff741

View file

@ -6,8 +6,12 @@ import { useJobs, useBulkDeleteJobs, useReprocessJob } from '../../hooks/useJob'
import { StatusBadge } from '../../components/StatusBadge';
import { useToastContext } from '../../contexts/ToastContext';
import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext';
import { apiClient } from '../../lib/api';
import type { Job } from '../../types/api';
// Statuses eligible for bulk download (approved or completed)
const DOWNLOADABLE_STATUSES = ['completed', 'approved_english', 'approved_source'];
const STATUS_OPTIONS = [
{ value: '', label: 'All Statuses' },
{ value: 'created,ingesting,ai_processing', label: 'Processing' },
@ -35,9 +39,11 @@ export function JobsList() {
const [sortBy, setSortBy] = useState('created_at_desc');
const [currentPage, setCurrentPage] = useState(1);
const [selectedJobs, setSelectedJobs] = useState<Set<string>>(new Set());
const [bulkAction, setBulkAction] = useState<'delete' | 'reprocess' | ''>('');
const [bulkAction, setBulkAction] = useState<'delete' | 'reprocess' | 'download' | ''>('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
const [showDownloadConfirm, setShowDownloadConfirm] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
const pageSize = 20;
// Update filters from URL params
@ -180,6 +186,123 @@ export function JobsList() {
}
};
// Check if a job is eligible for bulk download
const isDownloadableStatus = (status: string) => DOWNLOADABLE_STATUSES.includes(status);
// Get counts for download confirmation modal
const getDownloadEligibility = () => {
const selectedJobsList = filteredAndSortedJobs.filter(job => selectedJobs.has(job.id));
const eligibleJobs = selectedJobsList.filter(job => isDownloadableStatus(job.status));
const ineligibleJobs = selectedJobsList.filter(job => !isDownloadableStatus(job.status));
return { selectedJobsList, eligibleJobs, ineligibleJobs };
};
const handleBulkDownload = async () => {
setShowDownloadConfirm(false);
// Filter to eligible jobs
const eligibleJobs = filteredAndSortedJobs.filter(
job => selectedJobs.has(job.id) && isDownloadableStatus(job.status)
);
if (eligibleJobs.length === 0) {
toast.error('No eligible jobs to download');
return;
}
// Collect all files to download
const allFiles: { url: string; filename: string }[] = [];
for (const job of eligibleJobs) {
try {
const downloads = await apiClient.getJobDownloads(job.id);
// Sanitize job title for filename
const safeTitle = job.title.replace(/[^a-zA-Z0-9-_]/g, '_').substring(0, 50);
// Add source video
if (downloads.downloads.source_video) {
allFiles.push({
url: downloads.downloads.source_video as string,
filename: `${safeTitle}_source.mp4`
});
}
// Add per-language assets
for (const [lang, assets] of Object.entries(downloads.downloads)) {
if (lang === 'source_video') continue;
const langAssets = assets as Record<string, string>;
if (langAssets.captions_vtt) {
allFiles.push({
url: langAssets.captions_vtt,
filename: `${safeTitle}_${lang}_captions.vtt`
});
}
if (langAssets.audio_description_vtt) {
allFiles.push({
url: langAssets.audio_description_vtt,
filename: `${safeTitle}_${lang}_audio_description.vtt`
});
}
if (langAssets.audio_description_mp3) {
allFiles.push({
url: langAssets.audio_description_mp3,
filename: `${safeTitle}_${lang}_audio_description.mp3`
});
}
}
} catch (error) {
console.error(`Failed to get downloads for job ${job.id}:`, error);
}
}
if (allFiles.length === 0) {
toast.error('No files available to download');
return;
}
// Sequential download with progress
setDownloadProgress({ current: 0, total: allFiles.length });
let successCount = 0;
for (let i = 0; i < allFiles.length; i++) {
const { url, filename } = allFiles[i];
setDownloadProgress({ current: i + 1, total: allFiles.length });
try {
// Fetch and trigger download
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
successCount++;
// Small delay between downloads to avoid overwhelming browser
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.error(`Failed to download ${filename}:`, error);
}
}
setDownloadProgress(null);
if (successCount === allFiles.length) {
toast.success(`Downloaded ${allFiles.length} files from ${eligibleJobs.length} job(s)`);
} else {
toast.warning(`Downloaded ${successCount}/${allFiles.length} files. Some downloads failed.`);
}
clearSelection();
};
const isAdmin = user?.role === 'admin';
const isProduction = user?.role === 'production';
const canManageJobs = isAdmin || isProduction;
@ -348,12 +471,13 @@ export function JobsList() {
<>
<select
value={bulkAction}
onChange={(e) => setBulkAction(e.target.value as 'delete' | 'reprocess' | '')}
onChange={(e) => setBulkAction(e.target.value as 'delete' | 'reprocess' | 'download' | '')}
className="text-sm border border-gray-300 rounded px-2 py-1"
>
<option value="">Choose action...</option>
<option value="delete">Delete Selected</option>
<option value="reprocess">Reprocess Selected</option>
<option value="download">Download All Files</option>
</select>
{bulkAction === 'delete' && (
@ -376,6 +500,16 @@ export function JobsList() {
</button>
)}
{bulkAction === 'download' && (
<button
onClick={() => setShowDownloadConfirm(true)}
disabled={downloadProgress !== null}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Download Files
</button>
)}
<button
onClick={clearSelection}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
@ -637,6 +771,90 @@ export function JobsList() {
</div>
</div>
)}
{/* Download Confirmation Modal */}
{showDownloadConfirm && (() => {
const { eligibleJobs, ineligibleJobs } = getDownloadEligibility();
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-blue-100">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mt-4">
Download All Files
</h3>
<div className="mt-2 px-4 py-3">
<p className="text-sm text-gray-700">
You have selected <span className="font-semibold">{selectedJobs.size}</span> job{selectedJobs.size !== 1 ? 's' : ''}.{' '}
<span className="font-semibold text-blue-600">{eligibleJobs.length}</span>{' '}
{eligibleJobs.length === 1 ? 'is' : 'are'} eligible for download.
</p>
{ineligibleJobs.length > 0 && (
<div className="mt-3 p-3 bg-yellow-50 rounded-md">
<p className="text-sm text-yellow-800">
<strong>{ineligibleJobs.length}</strong> job{ineligibleJobs.length !== 1 ? 's are' : ' is'} not in approved/completed status and will be skipped.
</p>
</div>
)}
{eligibleJobs.length > 0 && (
<div className="mt-3 text-left">
<p className="text-sm text-gray-600 mb-2">Files to be downloaded per eligible job:</p>
<ul className="text-sm text-gray-500 space-y-1">
<li> Source video (MP4)</li>
<li> Captions (VTT) for all languages</li>
<li> Audio descriptions (VTT + MP3) for all languages</li>
</ul>
</div>
)}
</div>
<div className="items-center px-4 py-3 flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-3">
<button
onClick={() => setShowDownloadConfirm(false)}
className="px-4 py-2 bg-gray-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300 sm:w-auto"
>
Cancel
</button>
<button
onClick={handleBulkDownload}
disabled={eligibleJobs.length === 0}
className="px-4 py-2 bg-blue-600 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto"
>
Download
</button>
</div>
</div>
</div>
</div>
);
})()}
{/* Download Progress Indicator */}
{downloadProgress && (
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg p-4 z-50 border border-gray-200">
<div className="flex items-center gap-3">
<div className="animate-spin h-5 w-5 border-2 border-blue-600 border-t-transparent rounded-full" />
<span className="text-sm text-gray-700">
Downloading {downloadProgress.current} of {downloadProgress.total} files...
</span>
</div>
</div>
)}
</div>
);
}