/* * Copyright (C) 2025 - present Instructure, Inc. * * This file is part of Canvas. * * Canvas is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, version 3 of the License. * * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . */ import {Alert} from '@instructure/ui-alerts' import {Button} from '@instructure/ui-buttons' import {Flex} from '@instructure/ui-flex' import {FormField} from '@instructure/ui-form-field' import {Heading} from '@instructure/ui-heading' import {Text} from '@instructure/ui-text' import {TruncateText} from '@instructure/ui-truncate-text' import {View} from '@instructure/ui-view' import {useEffect, useRef, useState} from 'react' import CanvasSelect from '../shared/CanvasSelect' import {CC_FILE_MAX_BYTES} from '../shared/constants' import {trackPendoEvent} from '../utils/trackPendoEvent' import { type ClosedCaptionPanelTranslations, defaultClosedCaptionPanelTranslations, } from './translations' import {validateCaptionFile} from './utils/validation' interface ManualCaptionCreatorProps { languages: {id: string; label: string}[] onPrimary: (languageId: string, file: File) => void onCancel: () => void liveRegion: () => HTMLElement | null mountNode?: HTMLElement | (() => HTMLElement | null) onDirtyStateChanged?: (isDirty: boolean) => void elementRef?: (el: Element | null) => void translations?: ClosedCaptionPanelTranslations } const LANGUAGE_ERROR_ID = 'cc-language-error' const FILE_ERROR_ID = 'cc-file-error' export function ManualCaptionCreator({ languages, onPrimary, onCancel, liveRegion, mountNode, onDirtyStateChanged, elementRef, translations = defaultClosedCaptionPanelTranslations, }: ManualCaptionCreatorProps) { const [selectedLanguageId, setSelectedLanguageId] = useState('') const [selectedFile, setSelectedFile] = useState(null) const [showLanguageError, setShowLanguageError] = useState(false) const [fileValidationError, setFileValidationError] = useState('') const fileInputRef = useRef(null) const languageInputRef = useRef(null) const fileButtonRef = useRef(null) useEffect(() => { languageInputRef.current?.focus() }, []) const handleLanguageChange = (_event: React.SyntheticEvent, data: string) => { if (data) { setSelectedLanguageId(String(data)) setShowLanguageError(false) } onDirtyStateChanged?.(Boolean(data || selectedFile)) } const handleFileUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0] onDirtyStateChanged?.(Boolean(file || selectedLanguageId)) if (!file) return const validation = validateCaptionFile(file, translations) if (!validation.valid) { if (file.size > CC_FILE_MAX_BYTES) { trackPendoEvent('canvas_caption_validation_error', { flow_type: 'upload_file', error_type: 'file_too_large', }) } setFileValidationError(validation.error || '') setSelectedFile(null) } else { setFileValidationError('') setSelectedFile(file) } } const handleUploadClick = () => { const hasLanguageError = !selectedLanguageId const hasFileError = !selectedFile if (hasLanguageError) { trackPendoEvent('canvas_caption_validation_error', { flow_type: 'upload_file', error_type: 'missing_language', }) setShowLanguageError(true) } if (hasFileError) { trackPendoEvent('canvas_caption_validation_error', { flow_type: 'upload_file', error_type: 'missing_file', }) setFileValidationError(translations.pleaseSelectFileBeforeUploading()) } // Focus the first faulty field if (hasLanguageError) { languageInputRef.current?.focus() } else if (hasFileError) { fileButtonRef.current?.focus() } // Only proceed if both are selected if (selectedLanguageId && selectedFile) { onPrimary(selectedLanguageId, selectedFile) } } return ( {translations.addNewCaption()} {translations.uploadSubtitleHint()} { languageInputRef.current = el }} label="" placeholder={translations.selectLanguagePlaceholder()} value={selectedLanguageId} mountNode={mountNode} translatedStrings={{ USE_ARROWS: translations.useArrowKeys(), LIST_COLLAPSED: translations.listCollapsed(), LIST_EXPANDED: translations.listExpanded(), OPTION_SELECTED: '{option} selected.', }} onChange={handleLanguageChange} liveRegion={liveRegion} aria-invalid={showLanguageError ? 'true' : undefined} aria-describedby={showLanguageError ? LANGUAGE_ERROR_ID : undefined} messages={ showLanguageError ? [ { type: 'newError', text: translations.pleaseSelectLanguage(), }, ] : [] } > {languages.map(option => ( // @ts-expect-error - CanvasSelect.Option is a JS component without TS definitions {option.label} ))} {showLanguageError && ( {translations.pleaseSelectLanguage()} )} el?.style.setProperty('min-width', '0')} // to allow truncation messages={ fileValidationError ? [ { type: 'newError', text: fileValidationError, }, ] : [] } > {selectedFile ? ( {selectedFile.name} ) : ( {translations.noFileChosen()} )} {fileValidationError && ( {fileValidationError} )} ) }