feat(ux): R-8 linguist language warn, PM CC editing, timeline right-click + CC insert
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 <noreply@anthropic.com>
This commit is contained in:
parent
518796c852
commit
e4b350cd7d
8 changed files with 197 additions and 20 deletions
|
|
@ -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", []),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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<PausePointData | null>(null);
|
||||
const [editorPosition, setEditorPosition] = useState({ x: 0, y: 0 });
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(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({
|
|||
<div
|
||||
ref={timelineRef}
|
||||
className={`relative h-16 rounded-lg overflow-hidden ${isStale ? 'bg-amber-50' : 'bg-gray-100'}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Segments */}
|
||||
{segments.map((segment) => {
|
||||
|
|
@ -141,14 +184,13 @@ export function TimelinePreview({
|
|||
return (
|
||||
<div
|
||||
key={`pause-${pausePoint.cue_index}`}
|
||||
className={`absolute top-0 w-1 h-full cursor-pointer z-10 ${
|
||||
className={`absolute top-0 w-2 h-full cursor-pointer z-10 hover:w-3 transition-all ${
|
||||
isAdjusted ? 'bg-purple-600' : 'bg-red-600'
|
||||
} hover:w-2 transition-all`}
|
||||
style={{ left: `${leftPercent}%` }}
|
||||
onClick={(e) => 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 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={closeContextMenu} onContextMenu={(e) => { e.preventDefault(); closeContextMenu(); }} />
|
||||
<div
|
||||
className="fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[200px] text-sm"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-xs text-gray-500 border-b border-gray-100">
|
||||
{formatTime(contextMenu.timeMs)}
|
||||
</div>
|
||||
{contextMenu.nearPausePoint ? (
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={() => handleContextMenuPauseOpen(contextMenu.nearPausePoint!)}
|
||||
>
|
||||
✏️ Edit pause point {contextMenu.nearPausePoint.cue_index + 1} timing
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={() => { onRegenerateTTS(contextMenu.nearPausePoint!.cue_index); closeContextMenu(); }}
|
||||
>
|
||||
🔄 Regenerate TTS for cue {contextMenu.nearPausePoint.cue_index + 1}
|
||||
</button>
|
||||
</>
|
||||
) : onPausePointAdd ? (
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={() => { onPausePointAdd(contextMenu.timeMs); closeContextMenu(); }}
|
||||
>
|
||||
➕ Add AD cue at {formatTime(contextMenu.timeMs)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-gray-400 text-xs">No pause point nearby</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<VTTCue[]>([]);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [editingCue, setEditingCue] = useState<number | null>(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));
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [localAdVtt, setLocalAdVtt] = useState<string | null>(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 (
|
||||
<div className="border rounded-lg p-4 mb-4">
|
||||
|
|
@ -82,21 +104,25 @@ const LanguagePreview = ({
|
|||
)}
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
{previewType === 'captions' && vttContent?.captions_vtt && (
|
||||
{previewType === 'captions' && activeCaptionsVtt && (
|
||||
<VttEditor
|
||||
vttContent={vttContent.captions_vtt}
|
||||
onChange={() => {}} // 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 && (
|
||||
<VttEditor
|
||||
vttContent={vttContent.audio_description_vtt}
|
||||
onChange={() => {}} // 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export function QCDetail() {
|
|||
|
||||
const [reviewNotes, setReviewNotes] = useState('');
|
||||
const [costProjectId, setCostProjectId] = useState('');
|
||||
const [adInsertAtTimeMs, setAdInsertAtTimeMs] = useState<number | null>(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() {
|
|||
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
{/* 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 ? (
|
||||
<p className="mt-1.5 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
|
||||
⚠ {selected.full_name} has no {assignLanguage.toUpperCase()} competence listed. You can still assign, but double-check.
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Deadline (optional)</label>
|
||||
|
|
@ -1569,6 +1588,7 @@ export function QCDetail() {
|
|||
onRegenerateTTS={handleRegenerateTTS}
|
||||
regenerationQueue={pendingRegenerations}
|
||||
isStale={pausePointsModified || pendingRegenerations.length > 0 || adVttUploaded}
|
||||
onPausePointAdd={handlePausePointAdd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1670,6 +1690,8 @@ export function QCDetail() {
|
|||
readOnly={isProcessing}
|
||||
glossaryTerms={glossaryTerms}
|
||||
language={selectedLanguage}
|
||||
insertAtTimeMs={adInsertAtTimeMs}
|
||||
onInsertAtTimeDone={() => setAdInsertAtTimeMs(null)}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<VttDiffView jobId={id!} lang={selectedLanguage} kind="ad" />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue