From 1bf0fb9eed53cc18f171e6cd5ba28b7147f4429e Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 29 Apr 2026 18:42:03 +0100 Subject: [PATCH] feat(pr4+pr5): hotkeys, unified status labels, upload size constant PR-4 hotkeys (L-9): - QCDetail: Cmd/Ctrl+S saves current VTT (handleSaveFullVtt) - QCDetail: Escape closes both reject forms (final review + language reject modal) PR-5 T-1 (unified status labels): - Add JOB_STATUS_LABELS and getJobStatusLabel to utils/jobStatusMessages.ts - JobsList.tsx: remove local STATUS_LABELS duplicate, import from shared util - StatusBadge.tsx: remove 30-line switch duplicate, use getJobStatusLabel PR-5 T-14 (unified upload size constant): - config.py: upload_max_video_bytes = 2GB, upload_signed_url_ttl_hours = 24 - validation.py: use settings.upload_max_video_bytes instead of magic number - notify.py: use settings.upload_signed_url_ttl_hours for signed URL TTL Co-Authored-By: Claude Sonnet 4.6 --- backend/app/core/config.py | 4 +++ backend/app/middleware/validation.py | 5 +-- backend/app/tasks/notify.py | 6 ++-- frontend/src/components/StatusBadge.tsx | 36 ++------------------ frontend/src/routes/admin/QCDetail.tsx | 44 ++++++++++++++++++------- frontend/src/routes/jobs/JobsList.tsx | 25 +++----------- frontend/src/utils/jobStatusMessages.ts | 26 +++++++++++++-- 7 files changed, 73 insertions(+), 73 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 788188e..5eeb7ef 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -288,6 +288,10 @@ class Settings(BaseSettings): cost_tracker_source_app: str = "video-accessibility" cost_tracker_enabled: bool = True + # Upload limits (T-14 — single source of truth) + upload_max_video_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB + upload_signed_url_ttl_hours: int = 24 # signed URL lifetime + # CORS - comma-separated list of allowed origins cors_origins: str = "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:6001" diff --git a/backend/app/middleware/validation.py b/backend/app/middleware/validation.py index 95603ca..92f4092 100644 --- a/backend/app/middleware/validation.py +++ b/backend/app/middleware/validation.py @@ -3,6 +3,7 @@ import json import re import time +from ..core.config import settings from typing import Any, Dict, List, Optional, Set from fastapi import HTTPException, Request, status from fastapi.responses import JSONResponse @@ -67,8 +68,8 @@ class RequestValidator: self.compiled_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in self.malicious_patterns] - # Max file sizes (in bytes) - self.max_video_size = 2 * 1024 * 1024 * 1024 # 2GB + # Max file sizes (in bytes) — sourced from central config (T-14) + self.max_video_size = settings.upload_max_video_bytes self.max_subtitle_size = 10 * 1024 * 1024 # 10MB # Request size limits diff --git a/backend/app/tasks/notify.py b/backend/app/tasks/notify.py index f32403b..3ebca33 100644 --- a/backend/app/tasks/notify.py +++ b/backend/app/tasks/notify.py @@ -80,7 +80,7 @@ class NotifyClientTask(Task): if "captions_vtt_gcs" in lang_output: blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") try: - signed_url = await get_signed_download_url(blob_path, 24) + signed_url = await get_signed_download_url(blob_path, settings.upload_signed_url_ttl_hours) lang_downloads["captions_vtt"] = signed_url except Exception as e: logger.warning(f"Failed to generate signed URL for captions {language}: {e}") @@ -89,7 +89,7 @@ class NotifyClientTask(Task): if "ad_vtt_gcs" in lang_output: blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") try: - signed_url = await get_signed_download_url(blob_path, 24) + signed_url = await get_signed_download_url(blob_path, settings.upload_signed_url_ttl_hours) lang_downloads["audio_description_vtt"] = signed_url except Exception as e: logger.warning(f"Failed to generate signed URL for AD VTT {language}: {e}") @@ -98,7 +98,7 @@ class NotifyClientTask(Task): if "ad_mp3_gcs" in lang_output: blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") try: - signed_url = await get_signed_download_url(blob_path, 24) + signed_url = await get_signed_download_url(blob_path, settings.upload_signed_url_ttl_hours) lang_downloads["audio_description_mp3"] = signed_url except Exception as e: logger.warning(f"Failed to generate signed URL for AD MP3 {language}: {e}") diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index cb8fa03..b1d8663 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,4 +1,5 @@ import type { JobStatus } from '../types/api'; +import { getJobStatusLabel } from '../utils/jobStatusMessages'; interface StatusBadgeProps { status: JobStatus; @@ -37,40 +38,7 @@ export function StatusBadge({ status }: StatusBadgeProps) { } }; - const getStatusLabel = (status: JobStatus) => { - switch (status) { - case 'created': - return 'Created'; - case 'ingesting': - return 'Ingesting'; - case 'ai_processing': - return 'AI Processing'; - case 'pending_qc': - return 'Pending QC'; - case 'approved_english': - return 'Approved (EN)'; - case 'approved_source': - return 'Approved for Translation'; - case 'rejected': - return 'Rejected'; - case 'tts_failed': - return 'TTS Failed'; - case 'render_failed': - return 'Render Failed'; - case 'translating': - return 'Translating'; - case 'tts_generating': - return 'Generating Audio'; - case 'rendering_video': - return 'Rendering Video'; - case 'pending_final_review': - return 'Pending Final Review'; - case 'completed': - return 'Completed'; - default: - return status; - } - }; + const getStatusLabel = getJobStatusLabel; return ( diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index 87fe5d6..36bd289 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -335,23 +335,27 @@ export function QCDetail() { switch (event.key.toLowerCase()) { case 'a': - if (event.ctrlKey || event.metaKey) return; // Don't interfere with Ctrl+A + if (event.ctrlKey || event.metaKey) return; event.preventDefault(); - if (!isProcessing) { - handleApprove(); - } + if (!isProcessing) handleApprove(); break; case 'r': - if (event.ctrlKey || event.metaKey) return; // Don't interfere with Ctrl+R + if (event.ctrlKey || event.metaKey) return; event.preventDefault(); - if (!isProcessing && !showRejectForm) { - setShowRejectForm(true); + if (!isProcessing && !showRejectForm) setShowRejectForm(true); + break; + case 's': + if (event.ctrlKey || event.metaKey) { + // Cmd/Ctrl+S — save current VTT (full file) + event.preventDefault(); + if (!isProcessing && !updateVttMutation.isPending) { + handleSaveFullVtt(); + } } break; case 'escape': - if (showRejectForm) { - setShowRejectForm(false); - } + if (showRejectForm) setShowRejectForm(false); + if (showLangRejectModal) { setShowLangRejectModal(false); setLangRejectNotes(''); setLangRejectCategory(''); } break; case '1': setViewMode('side-by-side'); @@ -367,7 +371,7 @@ export function QCDetail() { document.addEventListener('keydown', handleKeyPress); return () => document.removeEventListener('keydown', handleKeyPress); - }, [isProcessing, showRejectForm]); + }, [isProcessing, showRejectForm, showLangRejectModal, updateVttMutation.isPending]); const handleCaptionsChange = (content: string) => { setCaptionsVtt(content); @@ -407,6 +411,24 @@ export function QCDetail() { } }; + // Cmd+S full-VTT save (hotkey handler) + const handleSaveFullVtt = async () => { + if (!id || (!captionsVtt && !adVtt)) return; + try { + await updateVttMutation.mutateAsync({ + id, + data: { + captions_vtt: captionsVtt || undefined, + audio_description_vtt: adVtt || undefined, + language: selectedLanguage, + } + }); + toast.toastOnly.success('VTT saved'); + } catch { + toast.toastOnly.error('Failed to save VTT'); + } + }; + // Immediate save handlers for individual cue edits const handleCaptionsCueSave = async (cueIndex: number, vttContent: string) => { if (!id) return; diff --git a/frontend/src/routes/jobs/JobsList.tsx b/frontend/src/routes/jobs/JobsList.tsx index 390bd51..95b078b 100644 --- a/frontend/src/routes/jobs/JobsList.tsx +++ b/frontend/src/routes/jobs/JobsList.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { formatDistanceToNow, subDays, isAfter, isPast, format } from 'date-fns'; import { useAuthStore } from '../../lib/auth'; +import { JOB_STATUS_LABELS, getJobStatusLabel } from '../../utils/jobStatusMessages'; import { useJobs, useBulkDeleteJobs, useReprocessJob, useBulkReturnToQC, useCloneJob } from '../../hooks/useJob'; import { StatusBadge } from '../../components/StatusBadge'; import { useToastContext } from '../../contexts/ToastContext'; @@ -18,26 +19,8 @@ const RETURN_TO_QC_ELIGIBLE_STATUSES = [ 'tts_failed', 'render_failed', 'approved_english', 'approved_source', ]; -// Human-readable status labels -const STATUS_LABELS: Record = { - 'created': 'Created', - 'ingesting': 'Ingesting', - 'ai_processing': 'AI Processing', - 'pending_qc': 'Pending QC', - 'approved_english': 'Approved (EN)', - 'approved_source': 'Approved for Translation', - 'rejected': 'Rejected', - 'qc_feedback': 'QC Feedback', - 'translating': 'Translating', - 'tts_generating': 'Generating Audio', - 'tts_failed': 'TTS Failed', - 'rendering_video': 'Rendering Video', - 'render_failed': 'Render Failed', - 'pending_final_review': 'Pending Final Review', - 'completed': 'Completed', -}; - -const getStatusLabel = (status: string): string => STATUS_LABELS[status] || status; +// Status labels imported from shared util (T-1) +const getStatusLabel = getJobStatusLabel; // Parse date string as UTC (backend returns UTC dates without timezone indicator) const parseUTCDate = (dateString: string): Date => { @@ -154,7 +137,7 @@ export function JobsList() { }, [jobsData?.jobs]); // All possible statuses for the dropdown (static, server-side filtered) - const uniqueStatuses = Object.keys(STATUS_LABELS); + const uniqueStatuses = Object.keys(JOB_STATUS_LABELS); // Handle column sort const handleSort = (column: string) => { diff --git a/frontend/src/utils/jobStatusMessages.ts b/frontend/src/utils/jobStatusMessages.ts index 981416c..f36679d 100644 --- a/frontend/src/utils/jobStatusMessages.ts +++ b/frontend/src/utils/jobStatusMessages.ts @@ -126,7 +126,7 @@ export function getStatusMessageConfig( */ export function getProgressMessage(status: string, progress?: number): string { const progressText = progress !== undefined ? ` (${progress}%)` : ''; - + switch (status) { case 'ingesting': return `Ingesting video${progressText}`; @@ -141,4 +141,26 @@ export function getProgressMessage(status: string, progress?: number): string { default: return status.replace(/_/g, ' '); } -} \ No newline at end of file +} + +/** Single source of truth for human-readable job status labels. */ +export const JOB_STATUS_LABELS: Record = { + created: 'Created', + ingesting: 'Ingesting', + ai_processing: 'AI Processing', + pending_qc: 'Pending QC', + approved_english: 'Approved (EN)', + approved_source: 'Approved for Translation', + rejected: 'Rejected', + qc_feedback: 'QC Feedback', + translating: 'Translating', + tts_generating: 'Generating Audio', + tts_failed: 'TTS Failed', + rendering_video: 'Rendering Video', + render_failed: 'Render Failed', + pending_final_review: 'Pending Final Review', + completed: 'Completed', +}; + +export const getJobStatusLabel = (status: string): string => + JOB_STATUS_LABELS[status] ?? status.replace(/_/g, ' '); \ No newline at end of file