jeanma's picture
Omnilingual ASR transcription demo
ae238b3 verified
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;