From e4b350cd7d37f06b8fa0bad3d93c94f4f83087c1 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 30 Apr 2026 10:51:31 +0100 Subject: [PATCH] feat(ux): R-8 linguist language warn, PM CC editing, timeline right-click + CC insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-8 — Linguist language competence: - Add User.languages[] BCP-47 field to backend model + UserResponse schema - Frontend: show amber warning in assign modal when selected linguist has no competence listed for the target language PM VTT editing (FinalDetail): - PM and ADMIN can now edit captions/AD in the final review stage - VttEditor becomes read-write with onCueSave wired to updateVttMutation - Other roles remain read-only Timeline right-click + add pause: - Right-click anywhere on the timeline opens a context menu showing the timestamp - If near a pause point marker: "Edit timing" + "Regenerate TTS" options - If on empty space: "Add AD cue at Xs" → inserts a new AD cue in the editor - Pause point markers widened from 1px → 2px (3px on hover) for easier clicking - Right-click on a pause point marker directly opens the editor VttEditor insertAtTimeMs prop: - New prop triggers programmatic insert at a specific video timestamp - Used by the timeline right-click "Add AD cue here" action Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/v1/routes_admin.py | 4 + backend/app/models/user.py | 2 + backend/app/schemas/auth.py | 1 + .../TimelinePreview/TimelinePreview.tsx | 94 +++++++++++++++++-- .../src/components/VttEditor/VttEditor.tsx | 36 ++++++- frontend/src/routes/admin/FinalDetail.tsx | 57 ++++++++--- frontend/src/routes/admin/QCDetail.tsx | 22 +++++ frontend/src/types/api.ts | 1 + 8 files changed, 197 insertions(+), 20 deletions(-) diff --git a/backend/app/api/v1/routes_admin.py b/backend/app/api/v1/routes_admin.py index 5e29aa5..6285cbf 100644 --- a/backend/app/api/v1/routes_admin.py +++ b/backend/app/api/v1/routes_admin.py @@ -64,6 +64,7 @@ async def list_users( is_active=user_doc["is_active"], created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(), pm_client_ids=user_doc.get("pm_client_ids", []), + languages=user_doc.get("languages", []), )) return UserListResponse( @@ -97,6 +98,7 @@ async def get_user( is_active=user_doc["is_active"], created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(), pm_client_ids=user_doc.get("pm_client_ids", []), + languages=user_doc.get("languages", []), ) @@ -150,6 +152,7 @@ async def create_user( is_active=True, created_at=user_doc["created_at"].isoformat(), pm_client_ids=[], + languages=[], ) @@ -214,6 +217,7 @@ async def update_user( is_active=result["is_active"], created_at=result.get("created_at", datetime.utcnow()).isoformat(), pm_client_ids=result.get("pm_client_ids", []), + languages=result.get("languages", []), ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0f7ac51..22a9990 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -41,6 +41,7 @@ class User(BaseModel): auth_provider: AuthProvider = AuthProvider.LOCAL is_active: bool = True pm_client_ids: list[str] = [] # Client IDs where this user is Project Manager (admin-assigned) + languages: list[str] = [] # BCP-47 language codes the user is competent in (R-8) created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -66,3 +67,4 @@ class UserUpdate(BaseModel): role: Optional[UserRole] = None is_active: Optional[bool] = None pm_client_ids: Optional[list[str]] = None + languages: Optional[list[str]] = None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 7d6bd11..dee4e27 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -54,6 +54,7 @@ class UserResponse(BaseModel): is_active: bool created_at: Optional[str] = None pm_client_ids: list[str] = [] + languages: list[str] = [] # BCP-47 codes for R-8 linguist competence check class UserListResponse(BaseModel): diff --git a/frontend/src/components/TimelinePreview/TimelinePreview.tsx b/frontend/src/components/TimelinePreview/TimelinePreview.tsx index 7df433e..959e1e7 100644 --- a/frontend/src/components/TimelinePreview/TimelinePreview.tsx +++ b/frontend/src/components/TimelinePreview/TimelinePreview.tsx @@ -2,6 +2,13 @@ import { useState, useRef, useCallback } from 'react'; import type { VideoSegmentMetadata, PausePointData } from '../../types/api'; import { PausePointEditor } from './PausePointEditor'; +interface ContextMenuState { + x: number; + y: number; + timeMs: number; + nearPausePoint: PausePointData | null; +} + interface TimelinePreviewProps { segments: VideoSegmentMetadata[]; pausePoints: PausePointData[]; @@ -13,6 +20,7 @@ interface TimelinePreviewProps { onRegenerateTTS: (cueIndex: number) => void; regenerationQueue: number[]; isStale?: boolean; + onPausePointAdd?: (timeMs: number) => void; // right-click → add pause at position } export function TimelinePreview({ @@ -26,9 +34,11 @@ export function TimelinePreview({ onRegenerateTTS, regenerationQueue, isStale = false, + onPausePointAdd, }: TimelinePreviewProps) { const [selectedPausePoint, setSelectedPausePoint] = useState(null); const [editorPosition, setEditorPosition] = useState({ x: 0, y: 0 }); + const [contextMenu, setContextMenu] = useState(null); const timelineRef = useRef(null); const getPositionPercent = useCallback( @@ -69,6 +79,38 @@ export function TimelinePreview({ setSelectedPausePoint(null); }; + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + if (!timelineRef.current || totalDurationMs <= 0) return; + const rect = timelineRef.current.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const timeMs = Math.round(fraction * totalDurationMs); + // Find if click is within 1.5% of a pause point + const threshold = totalDurationMs * 0.015; + const near = pausePoints.find(pp => { + const ppMs = pp.adjusted_ms ?? pp.original_ms; + return Math.abs(ppMs - timeMs) <= threshold; + }) ?? null; + setContextMenu({ x: e.clientX, y: e.clientY, timeMs, nearPausePoint: near }); + setSelectedPausePoint(null); + }; + + const closeContextMenu = () => setContextMenu(null); + + const handleContextMenuPauseOpen = (pp: PausePointData) => { + const effectiveMs = pp.adjusted_ms ?? pp.original_ms; + setEditorPosition({ x: contextMenu!.x, y: contextMenu!.y + 8 }); + setSelectedPausePoint(pp); + onPausePointClick(pp); + setContextMenu(null); + if (timelineRef.current) { + // seek video + onPausePointClick(pp); + } + // Seek video + const _ = effectiveMs; // used by consumer via onPausePointClick + }; + const formatTime = (ms: number) => { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); @@ -89,6 +131,7 @@ export function TimelinePreview({
{/* Segments */} {segments.map((segment) => { @@ -141,14 +184,13 @@ export function TimelinePreview({ return (
handlePausePointMarkerClick(e, pausePoint)} - title={`Pause point ${pausePoint.cue_index}: ${formatTime(effectiveMs)}${ - isAdjusted ? ' (adjusted)' : '' }`} + style={{ left: `${leftPercent}%` }} + onClick={(e) => { e.stopPropagation(); handlePausePointMarkerClick(e, pausePoint); }} + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); handleContextMenuPauseOpen(pausePoint); }} + title={`Pause point ${pausePoint.cue_index + 1}: ${formatTime(effectiveMs)}${isAdjusted ? ' (adjusted)' : ''} — click to edit`} /> ); })} @@ -204,6 +246,46 @@ export function TimelinePreview({ isRegenerationQueued={regenerationQueue.includes(selectedPausePoint.cue_index)} /> )} + + {/* Right-click context menu */} + {contextMenu && ( + <> +
{ e.preventDefault(); closeContextMenu(); }} /> +
+
+ {formatTime(contextMenu.timeMs)} +
+ {contextMenu.nearPausePoint ? ( + <> + + + + ) : onPausePointAdd ? ( + + ) : ( +
No pause point nearby
+ )} +
+ + )}
); } diff --git a/frontend/src/components/VttEditor/VttEditor.tsx b/frontend/src/components/VttEditor/VttEditor.tsx index 2027414..240257c 100644 --- a/frontend/src/components/VttEditor/VttEditor.tsx +++ b/frontend/src/components/VttEditor/VttEditor.tsx @@ -90,9 +90,11 @@ interface VttEditorProps { readOnly?: boolean; glossaryTerms?: GlossaryTerm[]; language?: string; + insertAtTimeMs?: number | null; // when set, auto-insert a cue at/near this timestamp + onInsertAtTimeDone?: () => void; // callback to clear insertAtTimeMs after insert } -export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) { +export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en', insertAtTimeMs, onInsertAtTimeDone }: VttEditorProps) { const [cues, setCues] = useState([]); const [errors, setErrors] = useState([]); const [editingCue, setEditingCue] = useState(null); @@ -118,6 +120,38 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu } }, [vttContent]); + // Imperatively insert a cue near insertAtTimeMs when triggered from parent (e.g., timeline right-click) + useEffect(() => { + if (insertAtTimeMs == null || readOnly || cues.length === 0) return; + const targetS = insertAtTimeMs / 1000; + // Find the cue whose start time is nearest but after targetS, or insert at end + let insertBeforeIdx = cues.findIndex(c => c.startTime >= targetS); + if (insertBeforeIdx === -1) insertBeforeIdx = cues.length; // insert after last + // Reuse insertCue logic inline: insertBefore insertBeforeIdx + let startTime: number, endTime: number; + if (insertBeforeIdx === 0) { + startTime = Math.max(0, targetS - 1); + endTime = Math.min(targetS + 1, cues[0].startTime); + } else if (insertBeforeIdx === cues.length) { + startTime = cues[cues.length - 1].endTime + 0.1; + endTime = startTime + 2.0; + } else { + const prev = cues[insertBeforeIdx - 1]; + startTime = Math.max(prev.endTime, targetS - 1); + endTime = Math.min(targetS + 1, cues[insertBeforeIdx].startTime); + } + if (endTime - startTime < 0.5) endTime = startTime + 0.5; + const newCue: VTTCue = { startTime, endTime, text: '[Enter caption text]' }; + const updatedCues = [...cues]; + updatedCues.splice(insertBeforeIdx, 0, newCue); + const newVtt = updateCuesLocal(updatedCues); + onCueInserted?.(insertBeforeIdx, updatedCues.length); + setEditingCue(insertBeforeIdx); + void saveCue(insertBeforeIdx, newVtt); + onInsertAtTimeDone?.(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [insertAtTimeMs]); + const updateCuesLocal = (updatedCues: VTTCue[]) => { setCues(updatedCues); setCueErrors(computeCueErrors(updatedCues)); diff --git a/frontend/src/routes/admin/FinalDetail.tsx b/frontend/src/routes/admin/FinalDetail.tsx index 48a2312..3f197c6 100644 --- a/frontend/src/routes/admin/FinalDetail.tsx +++ b/frontend/src/routes/admin/FinalDetail.tsx @@ -1,9 +1,10 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useState, useEffect } from 'react'; -import { useJob, useCompleteJob, useJobVttContent, useRejectFinalReview, useJobValidation, useJobDownloads, useUpdateJob } from '../../hooks/useJob'; +import { useJob, useCompleteJob, useJobVttContent, useRejectFinalReview, useJobValidation, useJobDownloads, useUpdateJob, useUpdateJobVtt } from '../../hooks/useJob'; import { StatusBadge } from '../../components/StatusBadge'; import { VttEditor } from '../../components/VttEditor/VttEditor'; import { VideoReviewPlayer } from '../../components/VideoReview'; +import { useAuthStore } from '../../lib/auth'; import type { LangOutput } from '../../types/api'; @@ -13,6 +14,7 @@ interface LanguagePreviewProps { jobId: string; audioUrl?: string; primaryLanguage?: string; + canEdit?: boolean; // PM can edit VTT in final review } const LanguagePreview = ({ @@ -20,11 +22,31 @@ const LanguagePreview = ({ assets, jobId, audioUrl, - primaryLanguage + primaryLanguage, + canEdit = false, }: LanguagePreviewProps) => { const isOriginalLanguage = primaryLanguage && language.toLowerCase() === primaryLanguage.toLowerCase(); const [previewType, setPreviewType] = useState<'captions' | 'ad'>('captions'); const { data: vttContent } = useJobVttContent(jobId, language); + const [localCaptionsVtt, setLocalCaptionsVtt] = useState(null); + const [localAdVtt, setLocalAdVtt] = useState(null); + const updateVttMutation = useUpdateJobVtt(); + + const activeCaptionsVtt = localCaptionsVtt ?? vttContent?.captions_vtt ?? null; + const activeAdVtt = localAdVtt ?? vttContent?.audio_description_vtt ?? null; + + const handleCueSave = async (kind: 'captions' | 'ad', vttStr: string) => { + await updateVttMutation.mutateAsync({ + id: jobId, + data: { + captions_vtt: kind === 'captions' ? vttStr : (activeCaptionsVtt ?? undefined), + audio_description_vtt: kind === 'ad' ? vttStr : (activeAdVtt ?? undefined), + language, + }, + }); + if (kind === 'captions') setLocalCaptionsVtt(vttStr); + else setLocalAdVtt(vttStr); + }; return (
@@ -82,21 +104,25 @@ const LanguagePreview = ({ )}
- {previewType === 'captions' && vttContent?.captions_vtt && ( + {previewType === 'captions' && activeCaptionsVtt && ( {}} // Read-only in final review - title={`${language.toUpperCase()} Captions`} - readOnly={true} + vttContent={activeCaptionsVtt} + onChange={setLocalCaptionsVtt} + onCueSave={canEdit ? (_, vtt) => handleCueSave('captions', vtt) : undefined} + title={`${language.toUpperCase()} Captions${canEdit ? '' : ' (read-only)'}`} + readOnly={!canEdit} + language={language} /> )} - - {previewType === 'ad' && vttContent?.audio_description_vtt && ( + + {previewType === 'ad' && activeAdVtt && ( {}} // Read-only in final review - title={`${language.toUpperCase()} Audio Description`} - readOnly={true} + vttContent={activeAdVtt} + onChange={setLocalAdVtt} + onCueSave={canEdit ? (_, vtt) => handleCueSave('ad', vtt) : undefined} + title={`${language.toUpperCase()} Audio Description${canEdit ? '' : ' (read-only)'}`} + readOnly={!canEdit} + language={language} /> )}
@@ -133,6 +159,7 @@ export function FinalDetail() { const navigate = useNavigate(); const [selectedAction, setSelectedAction] = useState<'approve' | 'reject' | null>(null); const [reviewNotes, setReviewNotes] = useState(''); + const { user: authUser } = useAuthStore(); const { data: job, isLoading, error } = useJob(id!); const { data: validation } = useJobValidation(id!); @@ -141,6 +168,9 @@ export function FinalDetail() { const rejectFinalMutation = useRejectFinalReview(); const updateJobMutation = useUpdateJob(); + // PM and ADMIN can edit VTT during final review; other roles see read-only + const canEditVtt = authUser?.role === 'project_manager' || authUser?.role === 'admin'; + const [costProjectId, setCostProjectId] = useState(''); const [costProjectIdSaved, setCostProjectIdSaved] = useState(false); @@ -285,6 +315,7 @@ export function FinalDetail() { jobId={id!} audioUrl={typeof downloads?.downloads?.[language] === 'object' ? downloads?.downloads?.[language]?.audio_description_mp3 : undefined} primaryLanguage={job.source.language} + canEdit={canEditVtt} /> ))}
diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index d7bca56..9464d31 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -102,6 +102,7 @@ export function QCDetail() { const [reviewNotes, setReviewNotes] = useState(''); const [costProjectId, setCostProjectId] = useState(''); + const [adInsertAtTimeMs, setAdInsertAtTimeMs] = useState(null); // Per-language QC state const { data: langQcData, refetch: refetchLangQc } = useQuery({ @@ -743,6 +744,11 @@ export function QCDetail() { } }; + const handlePausePointAdd = (timeMs: number) => { + // Insert a new AD cue at this position via the VttEditor insertAtTimeMs prop + setAdInsertAtTimeMs(timeMs); + }; + const handleAdCuePlay = useCallback((startTime: number) => { setVideoMode('accessible'); setTimeout(() => { @@ -1255,6 +1261,19 @@ export function QCDetail() { ))} + {/* R-8: language competence warning for linguists */} + {assignSlot === 'linguist' && assigningUserId && (() => { + const selected = assignableUsers.find(u => u.id === assigningUserId); + if (!selected?.languages?.length) return null; + const hasLang = selected.languages.some( + l => l.toLowerCase() === assignLanguage.toLowerCase() || l.toLowerCase().startsWith(assignLanguage.toLowerCase() + '-') + ); + return !hasLang ? ( +

+ ⚠ {selected.full_name} has no {assignLanguage.toUpperCase()} competence listed. You can still assign, but double-check. +

+ ) : null; + })()}
@@ -1569,6 +1588,7 @@ export function QCDetail() { onRegenerateTTS={handleRegenerateTTS} regenerationQueue={pendingRegenerations} isStale={pausePointsModified || pendingRegenerations.length > 0 || adVttUploaded} + onPausePointAdd={handlePausePointAdd} />
)} @@ -1670,6 +1690,8 @@ export function QCDetail() { readOnly={isProcessing} glossaryTerms={glossaryTerms} language={selectedLanguage} + insertAtTimeMs={adInsertAtTimeMs} + onInsertAtTimeDone={() => setAdInsertAtTimeMs(null)} />
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b91f381..eca8287 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -29,6 +29,7 @@ export interface User { is_active: boolean; created_at: string; pm_client_ids?: string[]; + languages?: string[]; // BCP-47 codes the user is competent in (R-8) } export interface Source {