mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
Handle restricted language configuration fallback V2 (#5154)
## Summary - restrict supported languages to the validated list from app-config instead of always adding an extra fallback - set the effective fallback locale to the preferred configured language and switch away from disallowed selections automatically ## Testing - ./gradlew build ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6930529bc6c08328a1ce05f7d1316e27)
This commit is contained in:
@@ -20,6 +20,7 @@ import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
@@ -81,6 +82,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
|
||||
@@ -14,8 +14,9 @@ export default function AppConfigLoader() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && config) {
|
||||
// Update supported languages if config specifies a language filter
|
||||
updateSupportedLanguages(config.languages);
|
||||
// Update supported languages and apply default locale from server config
|
||||
// Priority: localStorage > config.defaultLocale > browser detection > fallback
|
||||
updateSupportedLanguages(config.languages, config.defaultLocale);
|
||||
}
|
||||
}, [config, loading]);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '@app/i18n';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import styles from '@app/components/shared/LanguageSelector.module.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
// Types
|
||||
interface LanguageSelectorProps {
|
||||
@@ -163,13 +163,35 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
||||
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
|
||||
|
||||
// Get the filtered list of supported languages from i18n
|
||||
// This respects server config (ui.languages) applied by AppConfigLoader
|
||||
const allowedLanguages = (i18n.options.supportedLngs as string[] || [])
|
||||
.filter(lang => lang !== 'cimode'); // Exclude i18next debug language
|
||||
|
||||
const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
|
||||
.filter(([code]) => allowedLanguages.length === 0 || allowedLanguages.includes(code))
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
label: `${name} (${code})`,
|
||||
}));
|
||||
|
||||
// Hide the language selector if there's only one language option
|
||||
// (no point showing a selector when there's nothing to select)
|
||||
if (languageOptions.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate dropdown width and grid columns based on number of languages
|
||||
// 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols
|
||||
const dropdownWidth = languageOptions.length <= 4 ? 300
|
||||
: languageOptions.length <= 9 ? 400
|
||||
: 600;
|
||||
|
||||
const gridColumns = languageOptions.length <= 4 ? 2
|
||||
: languageOptions.length <= 9 ? 3
|
||||
: 4;
|
||||
|
||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||
// Create ripple effect at click position (only for button mode)
|
||||
if (!compact) {
|
||||
@@ -219,10 +241,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
width={dropdownWidth}
|
||||
position={position}
|
||||
offset={offset}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
zIndex={Z_INDEX_CONFIG_MODAL}
|
||||
withinPortal
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 120,
|
||||
@@ -278,21 +301,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
|
||||
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
}}
|
||||
>
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
const enabledLanguages = [
|
||||
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
|
||||
'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR',
|
||||
'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB',
|
||||
'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID',
|
||||
'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ'
|
||||
];
|
||||
const isDisabled = !enabledLanguages.includes(option.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.languageGrid}
|
||||
style={{ gridTemplateColumns: `repeat(${gridColumns}, 1fr)` }}
|
||||
>
|
||||
{languageOptions.map((option, index) => (
|
||||
<LanguageItem
|
||||
key={option.value}
|
||||
option={option}
|
||||
@@ -303,10 +318,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
rippleEffect={rippleEffect}
|
||||
pendingLanguage={pendingLanguage}
|
||||
compact={compact}
|
||||
disabled={isDisabled}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AppConfig {
|
||||
serverPort?: number;
|
||||
appNameNavbar?: string;
|
||||
languages?: string[];
|
||||
defaultLocale?: string;
|
||||
logoStyle?: 'modern' | 'classic';
|
||||
enableLogin?: boolean;
|
||||
enableEmailInvites?: boolean;
|
||||
|
||||
@@ -105,33 +105,94 @@ i18n.on('languageChanged', (lng) => {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
export function normalizeLanguageCode(languageCode: string): string {
|
||||
// Replace underscores with hyphens to align with i18next/translation file naming
|
||||
const hyphenated = languageCode.replace(/_/g, '-');
|
||||
const [base, ...rest] = hyphenated.split('-');
|
||||
|
||||
if (rest.length === 0) {
|
||||
return base.toLowerCase();
|
||||
}
|
||||
|
||||
const normalizedParts = rest.map(part => (part.length <= 3 ? part.toUpperCase() : part));
|
||||
return [base.toLowerCase(), ...normalizedParts].join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert language codes to underscore format (e.g., en-GB → en_GB)
|
||||
* Used for backend API communication which expects underscore format
|
||||
*/
|
||||
export function toUnderscoreFormat(languageCode: string): string {
|
||||
return languageCode.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of language codes to underscore format
|
||||
*/
|
||||
export function toUnderscoreLanguages(languages: string[]): string[] {
|
||||
return languages.map(toUnderscoreFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the supported languages list dynamically based on config
|
||||
* If configLanguages is null/empty, all languages remain available
|
||||
* Otherwise, only specified languages plus 'en-GB' fallback are enabled
|
||||
* Otherwise, only the specified languages are enabled with the first valid
|
||||
* option (preferring en-GB when present) used as the fallback language.
|
||||
*
|
||||
* @param configLanguages - Optional array of language codes from server config (ui.languages)
|
||||
* @param defaultLocale - Optional default language for new users (system.defaultLocale)
|
||||
*/
|
||||
export function updateSupportedLanguages(configLanguages?: string[] | null) {
|
||||
export function updateSupportedLanguages(configLanguages?: string[] | null, defaultLocale?: string | null) {
|
||||
// Normalize and validate default locale if provided
|
||||
const normalizedDefault = defaultLocale ? normalizeLanguageCode(defaultLocale) : null;
|
||||
const validDefault = normalizedDefault && normalizedDefault in supportedLanguages ? normalizedDefault : null;
|
||||
|
||||
if (!configLanguages || configLanguages.length === 0) {
|
||||
// No filter specified - keep all languages
|
||||
// But still apply default locale if provided and user has no preference
|
||||
if (validDefault) {
|
||||
applyDefaultLocale(validDefault);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure fallback language is always included
|
||||
const languagesToSupport = new Set(['en-GB', ...configLanguages]);
|
||||
const validLanguages = configLanguages
|
||||
.map(normalizeLanguageCode)
|
||||
.filter(lang => lang in supportedLanguages);
|
||||
|
||||
// Filter to only valid language codes that exist in our translations
|
||||
const validLanguages = Array.from(languagesToSupport).filter(
|
||||
lang => lang in supportedLanguages
|
||||
);
|
||||
// If no valid languages were provided, keep existing configuration
|
||||
if (validLanguages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (validLanguages.length > 0) {
|
||||
i18n.options.supportedLngs = validLanguages;
|
||||
// Determine fallback: prefer validDefault if in the list, then en-GB, then first valid language
|
||||
const fallback = validDefault && validLanguages.includes(validDefault)
|
||||
? validDefault
|
||||
: validLanguages.includes('en-GB')
|
||||
? 'en-GB'
|
||||
: validLanguages[0];
|
||||
|
||||
// If current language is not in the new supported list, switch to fallback
|
||||
const currentLang = i18n.language;
|
||||
if (currentLang && !validLanguages.includes(currentLang)) {
|
||||
i18n.changeLanguage('en-GB');
|
||||
}
|
||||
i18n.options.supportedLngs = validLanguages;
|
||||
i18n.options.fallbackLng = fallback;
|
||||
|
||||
// If current language is not in the new supported list, switch to fallback
|
||||
const currentLang = normalizeLanguageCode(i18n.language || '');
|
||||
if (currentLang && !validLanguages.includes(currentLang)) {
|
||||
i18n.changeLanguage(fallback);
|
||||
} else if (validDefault && !localStorage.getItem('i18nextLng')) {
|
||||
// User has no saved preference - apply server default
|
||||
i18n.changeLanguage(validDefault);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply server default locale when user has no saved language preference
|
||||
* This respects the priority: localStorage > defaultLocale > browser detection > fallback
|
||||
*/
|
||||
function applyDefaultLocale(defaultLocale: string) {
|
||||
// Only apply if user has no saved preference
|
||||
if (!localStorage.getItem('i18nextLng')) {
|
||||
i18n.changeLanguage(defaultLocale);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user