Spaces:
Running
on
A100
Running
on
A100
| import React, { useMemo, useState, useEffect } from 'react'; | |
| import Select, { components, StylesConfig, type SingleValue } from 'react-select'; | |
| import { matchSorter } from 'match-sorter'; | |
| import { LANGUAGE_MAP, ACCURATE_LANGUAGES } from '../utils/languages'; | |
| import { getScriptName, getScriptDescription } from '../utils/scripts'; | |
| import { getSupportedLanguages } from '../services/transcriptionApi'; | |
| interface LanguageSelectorProps { | |
| selectedLanguage: string | null; | |
| selectedScript: string | null; | |
| onLanguageAndScriptSelect: (language: string | null, script: string | null) => void; | |
| disabled?: boolean; | |
| } | |
| interface OptionType { | |
| value: string; // The full code_script combination | |
| label: string; | |
| languageName: string; | |
| scriptName: string; | |
| languageCode: string; | |
| scriptCode: string; | |
| } | |
| const parseLanguage = (languageString: string): OptionType | null => { | |
| const parts = languageString.split('_'); | |
| // Always expect format: "eng_Latn" | |
| if (parts.length === 2) { | |
| const [languageCode, scriptCode] = parts; | |
| const languageName = (LANGUAGE_MAP as Record<string, string>)[languageCode] || languageCode; | |
| const scriptName = getScriptName(scriptCode); | |
| return { | |
| value: languageString, | |
| label: `${languageName} ${scriptName} (${languageString})`, | |
| languageName, | |
| scriptName, | |
| languageCode, | |
| scriptCode, | |
| }; | |
| } | |
| return null; | |
| }; | |
| // Custom Option component to show language, script, and code with tooltip | |
| const Option = (props: any) => { | |
| const scriptDescription = getScriptDescription(props.data.scriptCode); | |
| return ( | |
| <components.Option {...props}> | |
| <div className="flex flex-col" title={scriptDescription || undefined}> | |
| <div className="font-medium text-sm">{props.data.languageName}</div> | |
| <div className="text-xs text-gray-400">{props.data.scriptName} ({props.data.value})</div> | |
| </div> | |
| </components.Option> | |
| ); | |
| }; | |
| // Custom SingleValue component for selected value | |
| const SingleValue = (props: any) => ( | |
| <components.SingleValue {...props}> | |
| <div className="flex flex-col"> | |
| <div className="font-medium text-sm leading-tight">{props.data.languageName}</div> | |
| <div className="text-xs text-gray-400 leading-tight">{props.data.scriptName} ({props.data.value})</div> | |
| </div> | |
| </components.SingleValue> | |
| ); | |
| // Custom styles to match the dark theme | |
| const customStyles: StylesConfig<OptionType> = { | |
| control: (styles, { isDisabled, isFocused }) => ({ | |
| ...styles, | |
| backgroundColor: isDisabled ? '#374151' : '#374151', // gray-700 | |
| borderColor: isFocused ? '#3b82f6' : '#4b5563', // blue-500 : gray-600 | |
| borderRadius: '0.375rem', | |
| minHeight: '40px', | |
| boxShadow: isFocused ? '0 0 0 1px #3b82f6' : 'none', | |
| '&:hover': { | |
| borderColor: isDisabled ? '#4b5563' : '#6b7280', // gray-600 : gray-500 | |
| backgroundColor: isDisabled ? '#374151' : '#4b5563', // gray-700 : gray-600 | |
| }, | |
| cursor: isDisabled ? 'not-allowed' : 'pointer', | |
| }), | |
| singleValue: (styles) => ({ | |
| ...styles, | |
| color: '#f9fafb', // gray-50 | |
| }), | |
| placeholder: (styles) => ({ | |
| ...styles, | |
| color: '#9ca3af', // gray-400 | |
| }), | |
| input: (styles) => ({ | |
| ...styles, | |
| color: '#f9fafb', // gray-50 | |
| }), | |
| menu: (styles) => ({ | |
| ...styles, | |
| backgroundColor: '#374151', // gray-700 | |
| border: '1px solid #4b5563', // gray-600 | |
| borderRadius: '0.5rem', | |
| boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', | |
| zIndex: 50, | |
| }), | |
| menuList: (styles) => ({ | |
| ...styles, | |
| maxHeight: '200px', | |
| padding: 0, | |
| }), | |
| option: (styles, { isFocused, isSelected }) => ({ | |
| ...styles, | |
| backgroundColor: isSelected | |
| ? '#2563eb' // blue-600 | |
| : isFocused | |
| ? '#4b5563' // gray-600 | |
| : 'transparent', | |
| color: '#f9fafb', // gray-50 | |
| cursor: 'pointer', | |
| padding: '8px 12px', | |
| '&:hover': { | |
| backgroundColor: isSelected ? '#2563eb' : '#4b5563', // blue-600 : gray-600 | |
| }, | |
| }), | |
| indicatorSeparator: (styles) => ({ | |
| ...styles, | |
| backgroundColor: '#4b5563', // gray-600 | |
| }), | |
| dropdownIndicator: (styles, { isDisabled }) => ({ | |
| ...styles, | |
| color: isDisabled ? '#6b7280' : '#9ca3af', // gray-500 : gray-400 | |
| '&:hover': { | |
| color: isDisabled ? '#6b7280' : '#d1d5db', // gray-500 : gray-300 | |
| }, | |
| }), | |
| clearIndicator: (styles) => ({ | |
| ...styles, | |
| color: '#9ca3af', // gray-400 | |
| '&:hover': { | |
| color: '#d1d5db', // gray-300 | |
| }, | |
| }), | |
| noOptionsMessage: (styles) => ({ | |
| ...styles, | |
| color: '#9ca3af', // gray-400 | |
| }), | |
| }; | |
| const LanguageSelector: React.FC<LanguageSelectorProps> = ({ | |
| selectedLanguage, | |
| selectedScript, | |
| onLanguageAndScriptSelect, | |
| disabled = false | |
| }) => { | |
| const [supportedLanguages, setSupportedLanguages] = useState<string[]>([]); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| // Fetch supported languages from API | |
| useEffect(() => { | |
| const fetchSupportedLanguages = async () => { | |
| try { | |
| setIsLoading(true); | |
| const languages = await getSupportedLanguages(); | |
| setSupportedLanguages(languages); | |
| } catch (err) { | |
| console.error('Failed to fetch supported languages:', err); | |
| setError('Failed to load supported languages'); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchSupportedLanguages(); | |
| }, []); | |
| // Convert supported languages to options | |
| const languageOptions = useMemo(() => { | |
| const allowAllLanguages = import.meta.env.VITE_ALLOW_ALL_LANGUAGES === 'true'; | |
| return supportedLanguages | |
| .map(parseLanguage) | |
| .filter((option): option is OptionType => option !== null) | |
| .filter((option) => { | |
| if (allowAllLanguages) { | |
| return true; | |
| } | |
| return ACCURATE_LANGUAGES.includes(option.languageCode); | |
| }) | |
| .sort((a, b) => a.languageName.localeCompare(b.languageName)); | |
| }, [supportedLanguages]); | |
| // Find the selected option | |
| const selectedOption = useMemo(() => { | |
| if (!selectedLanguage || !selectedScript) return null; | |
| const combinedValue = `${selectedLanguage}_${selectedScript}`; | |
| return languageOptions.find(option => option.value === combinedValue) || null; | |
| }, [selectedLanguage, selectedScript, languageOptions]); | |
| const handleChange = (newValue: SingleValue<OptionType>) => { | |
| if (newValue) { | |
| onLanguageAndScriptSelect(newValue.languageCode, newValue.scriptCode); | |
| } else { | |
| onLanguageAndScriptSelect(null, null); | |
| } | |
| }; | |
| // Custom filterOption function using match-sorter | |
| const filterOptions = useMemo(() => { | |
| return (option: { label: string; value: string; data: OptionType }, inputValue: string) => { | |
| if (!inputValue.trim()) return true; | |
| // Use match-sorter to check if this individual option matches | |
| const matches = matchSorter([option.data], inputValue, { | |
| keys: [ | |
| 'languageName', // Primary: language name | |
| 'scriptName', // Secondary: script name | |
| 'languageCode', // Tertiary: language code | |
| 'scriptCode', // Quaternary: script code | |
| 'label', // Fallback: full label | |
| ], | |
| threshold: matchSorter.rankings.CONTAINS, | |
| }); | |
| return matches.length > 0; | |
| }; | |
| }, []); | |
| if (error) { | |
| return ( | |
| <div className="text-red-400 text-sm p-2 bg-red-900/20 rounded"> | |
| {error} | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <Select<OptionType> | |
| value={selectedOption} | |
| onChange={handleChange} | |
| options={languageOptions} | |
| placeholder={isLoading ? "Loading languages..." : "Select language..."} | |
| isClearable | |
| isDisabled={disabled || isLoading} | |
| isSearchable | |
| filterOption={(option, inputValue) => filterOptions(option, inputValue)} | |
| components={{ Option, SingleValue }} | |
| styles={customStyles} | |
| menuPortalTarget={document.body} | |
| menuPosition="fixed" | |
| noOptionsMessage={({ inputValue }) => | |
| `No languages found matching "${inputValue}"` | |
| } | |
| // Performance optimizations | |
| menuIsOpen={undefined} // Let react-select manage this | |
| blurInputOnSelect={true} | |
| closeMenuOnSelect={true} | |
| hideSelectedOptions={false} | |
| /> | |
| ); | |
| }; | |
| export default LanguageSelector; | |