280 lines
8.6 KiB
TypeScript
280 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
|
|
import { DEFAULT_LANGUAGE } from './config';
|
|
import { en } from './translations/en';
|
|
import { de } from './translations/de';
|
|
import { sl } from './translations/sl';
|
|
import { flattenObject } from './dynamicTranslations';
|
|
import { authFetch } from '../utils/authFetch';
|
|
import useAuthStore from '../store/authStore';
|
|
import { API_ENDPOINTS } from '../utils/api';
|
|
|
|
type LanguageEntry = {
|
|
code: string;
|
|
name: string;
|
|
};
|
|
|
|
type TranslationFilesPayload = {
|
|
languages: LanguageEntry[];
|
|
translations: Record<string, Record<string, string>>;
|
|
};
|
|
|
|
const builtInTranslations: Record<string, Record<string, unknown>> = {
|
|
en: en as unknown as Record<string, unknown>,
|
|
de: de as unknown as Record<string, unknown>,
|
|
sl: sl as unknown as Record<string, unknown>,
|
|
};
|
|
|
|
const APP_LANGUAGE_STORAGE_KEY = 'pp-selected-language';
|
|
|
|
// Flat map of English keys used as canonical key list and fallback
|
|
const enFlat = flattenObject(en as unknown as Record<string, unknown>);
|
|
|
|
function normalizeLanguageValue(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const normalized = value.trim().toLowerCase();
|
|
return normalized ? normalized : null;
|
|
}
|
|
|
|
function extractPreferredLanguage(payload: unknown): string | null {
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
|
|
const settings = (payload as { settings?: Record<string, unknown> }).settings;
|
|
if (!settings || typeof settings !== 'object') return null;
|
|
|
|
return normalizeLanguageValue(
|
|
settings.preferred_language
|
|
?? settings.preferredLanguage
|
|
?? settings.language
|
|
?? settings.lang,
|
|
);
|
|
}
|
|
|
|
function getNestedValue(root: Record<string, unknown>, key: string): string | null {
|
|
const segments = key.split('.');
|
|
let current: unknown = root;
|
|
|
|
for (const segment of segments) {
|
|
if (!current || typeof current !== 'object') return null;
|
|
current = (current as Record<string, unknown>)[segment];
|
|
}
|
|
|
|
return typeof current === 'string' ? current : null;
|
|
}
|
|
|
|
interface I18nContextType {
|
|
language: string;
|
|
setLanguage: (lang: string) => void;
|
|
t: (key: string) => string;
|
|
languages: LanguageEntry[];
|
|
reloadTranslations: () => Promise<void>;
|
|
}
|
|
|
|
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
|
|
|
interface I18nProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function I18nProvider({ children }: I18nProviderProps) {
|
|
const accessToken = useAuthStore((state) => state.accessToken);
|
|
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
|
|
const [hasSyncedStoredLanguage, setHasSyncedStoredLanguage] = useState(false);
|
|
const [hasResolvedPersistedLanguage, setHasResolvedPersistedLanguage] = useState(false);
|
|
const lastPersistedLanguageRef = useRef<string | null>(null);
|
|
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
|
|
languages: [
|
|
{ code: 'en', name: 'English' },
|
|
{ code: 'de', name: 'Deutsch' },
|
|
{ code: 'sl', name: 'Slovenian' },
|
|
],
|
|
translations: {
|
|
en: enFlat,
|
|
de: flattenObject(de as unknown as Record<string, unknown>),
|
|
sl: flattenObject(sl as unknown as Record<string, unknown>),
|
|
},
|
|
});
|
|
|
|
const reloadTranslations = useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
|
const result = await response.json();
|
|
|
|
if (!response.ok || !result?.ok) return;
|
|
|
|
const languages = Array.isArray(result.languages)
|
|
? result.languages.filter((lang: unknown): lang is LanguageEntry => {
|
|
if (!lang || typeof lang !== 'object') return false;
|
|
const entry = lang as LanguageEntry;
|
|
return typeof entry.code === 'string' && typeof entry.name === 'string';
|
|
})
|
|
: [];
|
|
|
|
const translations = result.translations && typeof result.translations === 'object'
|
|
? result.translations as Record<string, Record<string, string>>
|
|
: {};
|
|
|
|
if (languages.length > 0) {
|
|
setTranslationFiles({ languages, translations });
|
|
}
|
|
} catch {
|
|
// Keep built-in fallback translations if API is unavailable.
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void reloadTranslations();
|
|
}, [reloadTranslations]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const stored = window.localStorage.getItem(APP_LANGUAGE_STORAGE_KEY);
|
|
if (stored && stored.trim()) {
|
|
setLanguage((currentLanguage) => currentLanguage === stored ? currentLanguage : stored);
|
|
}
|
|
|
|
setHasSyncedStoredLanguage(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
lastPersistedLanguageRef.current = null;
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (translationFiles.languages.length === 0) return;
|
|
if (translationFiles.languages.some((entry) => entry.code === language)) return;
|
|
|
|
const fallback = translationFiles.languages.find((entry) => entry.code === DEFAULT_LANGUAGE)?.code
|
|
?? translationFiles.languages[0]?.code
|
|
?? DEFAULT_LANGUAGE;
|
|
setLanguage(fallback);
|
|
}, [translationFiles.languages, language]);
|
|
|
|
useEffect(() => {
|
|
if (!hasSyncedStoredLanguage) return;
|
|
|
|
let cancelled = false;
|
|
|
|
async function syncPersistedLanguage() {
|
|
if (!accessToken) {
|
|
setHasResolvedPersistedLanguage(true);
|
|
return;
|
|
}
|
|
|
|
setHasResolvedPersistedLanguage(false);
|
|
try {
|
|
const response = await authFetch(API_ENDPOINTS.USER_SETTINGS, {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
});
|
|
const result = await response.json().catch(() => null);
|
|
const preferredLanguage = response.ok ? extractPreferredLanguage(result) : null;
|
|
|
|
if (!cancelled && preferredLanguage) {
|
|
lastPersistedLanguageRef.current = preferredLanguage;
|
|
setLanguage((currentLanguage) => currentLanguage === preferredLanguage ? currentLanguage : preferredLanguage);
|
|
}
|
|
} catch {
|
|
// Ignore preference sync failures and keep local state.
|
|
} finally {
|
|
if (!cancelled) {
|
|
setHasResolvedPersistedLanguage(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
void syncPersistedLanguage();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [accessToken, hasSyncedStoredLanguage]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined' || !hasSyncedStoredLanguage) return;
|
|
|
|
window.localStorage.setItem(APP_LANGUAGE_STORAGE_KEY, language);
|
|
document.documentElement.lang = language;
|
|
|
|
if (!accessToken || !hasResolvedPersistedLanguage || lastPersistedLanguageRef.current === language) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
async function persistPreferredLanguage() {
|
|
try {
|
|
const response = await authFetch(API_ENDPOINTS.USER_SETTINGS, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ preferredLanguage: language }),
|
|
});
|
|
|
|
if (!cancelled && response.ok) {
|
|
lastPersistedLanguageRef.current = language;
|
|
}
|
|
} catch {
|
|
// Ignore persistence failures; localStorage remains as the client fallback.
|
|
}
|
|
}
|
|
|
|
void persistPreferredLanguage();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [accessToken, hasResolvedPersistedLanguage, hasSyncedStoredLanguage, language]);
|
|
|
|
const t = useCallback((key: string): string => {
|
|
// 1. Check translation loaded from translation files API.
|
|
const fileValue = translationFiles.translations[language]?.[key];
|
|
if (fileValue !== undefined && fileValue !== '') return fileValue;
|
|
|
|
// 2. Check built-in static imports (works even before API load).
|
|
const builtIn = builtInTranslations[language];
|
|
if (builtIn) {
|
|
const value = getNestedValue(builtIn, key);
|
|
if (value !== null) return value;
|
|
}
|
|
|
|
// 3. Fallback to English.
|
|
return enFlat[key] ?? key;
|
|
}, [language, translationFiles.translations]);
|
|
|
|
return (
|
|
<I18nContext.Provider
|
|
value={{
|
|
language,
|
|
setLanguage,
|
|
t,
|
|
languages: translationFiles.languages,
|
|
reloadTranslations,
|
|
}}
|
|
>
|
|
{children}
|
|
</I18nContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTranslation() {
|
|
const context = useContext(I18nContext);
|
|
if (context === undefined) {
|
|
throw new Error('useTranslation must be used within an I18nProvider');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
/** Returns all known translation keys (from English as source of truth) */
|
|
export function getAllTranslationKeys(): string[] {
|
|
return Object.keys(enFlat);
|
|
}
|
|
|
|
/** Returns the English value for a key (used as reference in admin UI) */
|
|
export function getEnglishValue(key: string): string {
|
|
return enFlat[key] ?? key;
|
|
}
|