diff --git a/.gitignore b/.gitignore index 3ceb93f..754f53d 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,14 @@ docs/*.pdf /var/www/html/video-accessibility.backup.* backend/.env + +# Node / npm artifacts at repo root (Playwright MCP installs these) +node_modules/ +package.json +package-lock.json + +# Playwright MCP session snapshots +.playwright-mcp/ + +# Test videos +test-video.mp4 diff --git a/backend/app/api/v1/routes_admin.py b/backend/app/api/v1/routes_admin.py index 93e037b..b1f3eeb 100644 --- a/backend/app/api/v1/routes_admin.py +++ b/backend/app/api/v1/routes_admin.py @@ -6,7 +6,7 @@ from motor.motor_asyncio import AsyncIOMotorDatabase from ...core.authz import MembershipContext, get_membership_context from ...core.database import get_database -from ...core.dependencies import require_roles +from ...core.dependencies import get_current_user, require_roles from ...core.logging import get_logger from ...core.security import get_password_hash from ...models.audit_log import AuditAction, AuditLogQuery, AuditLogResponse @@ -96,6 +96,32 @@ async def list_users( ) +@router.get("/brief-assignees", response_model=list[UserResponse]) +async def list_brief_assignees( + current_user: User = Depends(get_current_user), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Return users who can be assigned a brief (PM, production, admin). Accessible to all brief-creating roles.""" + docs = await db.users.find( + { + "role": {"$in": [UserRole.ADMIN.value, UserRole.PROJECT_MANAGER.value, UserRole.PRODUCTION.value]}, + "is_active": True, + }, + {"hashed_password": 0}, + ).sort("full_name", 1).to_list(None) + return [UserResponse( + id=str(d["_id"]), + email=d["email"], + full_name=d["full_name"], + role=d["role"], + auth_provider=d.get("auth_provider", "local"), + is_active=d["is_active"], + created_at=d.get("created_at", datetime.utcnow()).isoformat() if d.get("created_at") else None, + pm_client_ids=d.get("pm_client_ids", []), + languages=d.get("languages", []), + ) for d in docs] + + @router.get("/users/{user_id}", response_model=UserResponse) async def get_user( user_id: str, diff --git a/backend/app/api/v1/routes_clients.py b/backend/app/api/v1/routes_clients.py index c9d3306..1cd91bc 100644 --- a/backend/app/api/v1/routes_clients.py +++ b/backend/app/api/v1/routes_clients.py @@ -379,7 +379,7 @@ async def list_all_projects( db: AsyncIOMotorDatabase = Depends(get_database), ): """Return all active projects accessible to the current user (across all clients).""" - if current_user.role in (UserRole.ADMIN, UserRole.PRODUCTION): + if current_user.role in (UserRole.ADMIN, UserRole.PRODUCTION, UserRole.PROJECT_MANAGER): docs = await db.projects.find({"is_active": True}).to_list(None) else: accessible_client_ids = await _get_accessible_client_ids(current_user, db) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 0c3fd84..2bbf167 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -76,6 +76,7 @@ class RequestedOutputs(BaseModel): accessible_video_mp4: bool = False # Rendered video with embedded audio descriptions accessible_video_method: Literal["overlay", "pause_insert"] | None = None # User-selected method sdh_vtt: bool = False # SDH (Subtitles for Deaf and Hard of Hearing) captions with speaker labels, sound effects, music notation + descriptive_transcript: bool = False # WCAG-compliant combined speech+description transcript text file languages: list[str] = [] transcreation: list[str] = [] tts_preferences: TTSPreferences | None = None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b28bb2b..8f643cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -198,21 +198,21 @@ function AppContent() { } /> - + } /> - + } /> - + diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index c378410..dc536e3 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -302,6 +302,13 @@ export function useMyMemberships() { }); } +export function useBriefAssignees() { + return useQuery({ + queryKey: ['brief-assignees'], + queryFn: () => apiClient.listBriefAssignees(), + }); +} + // ── Invitations ─────────────────────────────────────────────────────────────── export function useInvitations(orgId: string) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2ee122f..d31f32f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -390,6 +390,11 @@ class ApiClient { return response.data; } + async listBriefAssignees(): Promise { + const response = await this.client.get('/admin/brief-assignees'); + return response.data; + } + async createBrief(data: import('../types/api').JobBriefCreate): Promise { const response = await this.client.post('/briefs', data); return response.data; diff --git a/frontend/src/routes/briefs/NewBrief.tsx b/frontend/src/routes/briefs/NewBrief.tsx index 2f976a0..4ad803e 100644 --- a/frontend/src/routes/briefs/NewBrief.tsx +++ b/frontend/src/routes/briefs/NewBrief.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useCreateBrief } from '../../hooks/useJob'; import { useToastContext } from '../../contexts/ToastContext'; -import { useAllProjects, useOrganizations, useOrgMembers } from '../../hooks/useClients'; +import { useAllProjects, useBriefAssignees } from '../../hooks/useClients'; const ALL_LANGUAGES: { code: string; label: string }[] = [ { code: 'en', label: 'EN' }, @@ -68,14 +68,18 @@ export function NewBrief() { const [deadline, setDeadline] = useState(''); const [projectId, setProjectId] = useState(''); const [assigneeId, setAssigneeId] = useState(''); + + // Requested outputs const [captionsVtt, setCaptionsVtt] = useState(true); const [adVtt, setAdVtt] = useState(true); const [adMp3, setAdMp3] = useState(true); + const [accessibleMp4, setAccessibleMp4] = useState(false); + const [accessibleMethod, setAccessibleMethod] = useState<'overlay' | 'pause_insert'>('pause_insert'); + const [sdhVtt, setSdhVtt] = useState(false); + const [descriptiveTranscript, setDescriptiveTranscript] = useState(false); const { data: projects = [] } = useAllProjects(); - const { data: organizations = [] } = useOrganizations(); - const firstOrgId = organizations[0]?.id ?? ''; - const { data: members = [] } = useOrgMembers(firstOrgId); + const { data: assignees = [] } = useBriefAssignees(); const toggleLang = (lang: string) => { setLanguages(prev => @@ -97,8 +101,10 @@ export function NewBrief() { captions_vtt: captionsVtt, audio_description_vtt: adVtt, audio_description_mp3: adMp3, - accessible_video_mp4: false, - sdh_vtt: false, + accessible_video_mp4: accessibleMp4, + accessible_video_method: accessibleMp4 ? accessibleMethod : undefined, + sdh_vtt: sdhVtt, + descriptive_transcript: descriptiveTranscript, languages, transcreation: [], translation_mode: 'video_native', @@ -167,9 +173,9 @@ export function NewBrief() { className="w-full border border-gray-300 rounded px-3 py-2 text-sm" > - {members.map(m => ( - ))} @@ -177,17 +183,60 @@ export function NewBrief() {
-
+
{([ - [captionsVtt, setCaptionsVtt, 'Captions (VTT)'], - [adVtt, setAdVtt, 'Audio Descriptions (VTT)'], - [adMp3, setAdMp3, 'Audio Descriptions (MP3)'], - ] as [boolean, (v: boolean) => void, string][]).map(([val, setter, label]) => ( -
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d7b95b1..e2421d4 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -70,6 +70,7 @@ export interface RequestedOutputs { accessible_video_mp4: boolean; // Rendered video with embedded audio descriptions accessible_video_method?: AccessibleVideoMethod; // User-selected method for accessible video sdh_vtt?: boolean; // SDH captions with speaker labels, sound effects, music notation + descriptive_transcript?: boolean; // WCAG-compliant combined speech+description transcript languages: string[]; transcreation: string[]; tts_preferences?: TTSPreferences;