feat(l1-l6): glossary inline highlights + CPS warning in VttEditor

VttEditor:
- New props: glossaryTerms and language
- Glossary: source_term occurrences underlined (amber) with preferred translation
  tooltip on hover; only terms that have a translation for the current language
- CPS badge:  N CPS shown in amber when characters-per-second > 20

QCDetail:
- Fetches active glossary for job's client (getGlossaries → find one with
  current_version_id → getGlossaryTerms up to 500 terms)
- Passes glossaryTerms + language to both Captions and AD VttEditor instances

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 18:58:59 +01:00
parent abf81515a4
commit bb751033c0
2 changed files with 92 additions and 2 deletions

View file

@ -1,5 +1,48 @@
import { useState, useEffect, useRef } from 'react';
import { VTTParser, VTTValidator, type VTTCue } from '../../lib/vtt';
import type { GlossaryTerm } from '../../types/api';
const CPS_THRESHOLD = 20;
function computeCps(cue: VTTCue): number {
const chars = cue.text.replace(/[\n\r]/g, ' ').trim().length;
const duration = cue.endTime - cue.startTime;
if (duration <= 0) return 0;
return chars / duration;
}
type TextSegment = { text: string; glossaryMatch?: { preferred: string; notes?: string } };
function buildSegments(text: string, glossaryTerms: GlossaryTerm[], language: string): TextSegment[] {
if (!glossaryTerms.length) return [{ text }];
// Build a combined regex from all source terms
const patterns = glossaryTerms
.filter(t => t.translations[language])
.map(t => ({ pattern: t.source_term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), term: t }));
if (!patterns.length) return [{ text }];
const regex = new RegExp(`(${patterns.map(p => p.pattern).join('|')})`, 'gi');
const parts = text.split(regex);
const segments: TextSegment[] = [];
for (const part of parts) {
if (!part) continue;
const matchedTerm = patterns.find(p => new RegExp(`^${p.pattern}$`, 'i').test(part));
if (matchedTerm) {
segments.push({
text: part,
glossaryMatch: {
preferred: matchedTerm.term.translations[language] || '',
notes: undefined,
},
});
} else {
segments.push({ text: part });
}
}
return segments;
}
// Pure function for validating a single cue's timing (no hooks dependency)
function validateCueTimingAtIndex(allCues: VTTCue[], index: number): string[] {
@ -44,9 +87,11 @@ interface VttEditorProps {
onCueDeleted?: (deletedIndex: number, totalCuesAfterDelete: number) => void;
title: string;
readOnly?: boolean;
glossaryTerms?: GlossaryTerm[];
language?: string;
}
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, title, readOnly = false }: VttEditorProps) {
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) {
const [cues, setCues] = useState<VTTCue[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [editingCue, setEditingCue] = useState<number | null>(null);
@ -310,6 +355,18 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
</div>
)}
{/* CPS badge */}
{(() => {
const cps = computeCps(cue);
return cps > CPS_THRESHOLD ? (
<div className="mb-1 flex items-center gap-1">
<span className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded font-medium" title={`${cps.toFixed(1)} characters per second — above the ${CPS_THRESHOLD} CPS recommended limit`}>
{cps.toFixed(0)} CPS
</span>
</div>
) : null;
})()}
{/* Text Content */}
{editingCue === index ? (
<CueEditor
@ -320,7 +377,19 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
) : (
<div className="group">
<div className="text-gray-900 whitespace-pre-wrap leading-relaxed">
{cue.text}
{buildSegments(cue.text, glossaryTerms, language).map((seg, si) =>
seg.glossaryMatch ? (
<span
key={si}
className="underline decoration-amber-400 decoration-2 underline-offset-2 cursor-help"
title={`Preferred: ${seg.glossaryMatch.preferred}`}
>
{seg.text}
</span>
) : (
<span key={si}>{seg.text}</span>
)
)}
</div>
{!readOnly && (
<button

View file

@ -109,6 +109,23 @@ export function QCDetail() {
});
const langQcMap = langQcData?.language_qc ?? {};
// Glossary terms for inline highlighting
const clientId = job?.client_id;
const { data: glossariesData } = useQuery({
queryKey: ['client-glossaries', clientId],
queryFn: () => apiClient.getGlossaries(clientId!),
enabled: !!clientId,
staleTime: 60_000,
});
const activeGlossaryId = glossariesData?.find(g => g.current_version_id)?.id;
const { data: glossaryTermsData } = useQuery({
queryKey: ['glossary-terms', clientId, activeGlossaryId],
queryFn: () => apiClient.getGlossaryTerms(clientId!, activeGlossaryId!, { pageSize: 500 }),
enabled: !!clientId && !!activeGlossaryId,
staleTime: 120_000,
});
const glossaryTerms = glossaryTermsData?.terms ?? [];
const [showLangRejectModal, setShowLangRejectModal] = useState(false);
const [langRejectNotes, setLangRejectNotes] = useState('');
const [langRejectCategory, setLangRejectCategory] = useState('');
@ -1551,6 +1568,8 @@ export function QCDetail() {
onCueSave={handleCaptionsCueSave}
title={`Closed Captions (${selectedLanguage.toUpperCase()})`}
readOnly={isProcessing}
glossaryTerms={glossaryTerms}
language={selectedLanguage}
/>
</div>
)}
@ -1576,6 +1595,8 @@ export function QCDetail() {
onCueDeleted={handleAdCueDeleted}
title={`Audio Description (${selectedLanguage.toUpperCase()})`}
readOnly={isProcessing}
glossaryTerms={glossaryTerms}
language={selectedLanguage}
/>
</div>
)}