263 lines
6.9 KiB
TypeScript
263 lines
6.9 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from "react"
|
|
import { NextIntlClientProvider, type AbstractIntlMessages } from "next-intl"
|
|
import { getFallbackMessages, getMessagesForLocale } from "@/i18n/messages"
|
|
import {
|
|
fromIntlLocale,
|
|
getSystemLocaleCandidates,
|
|
LANGUAGE_COOKIE_KEY,
|
|
LANGUAGE_MODE_COOKIE_KEY,
|
|
LANGUAGE_SETTINGS_STORAGE_KEY,
|
|
normalizeLanguageSettings,
|
|
resolveAppLocale,
|
|
toIntlLocale,
|
|
type IntlLocale,
|
|
} from "@/lib/i18n"
|
|
import { getSystemLanguageSettings } from "@/lib/tauri"
|
|
import { AppBootLoading } from "@/components/layout/app-boot-loading"
|
|
import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
|
|
|
interface AppI18nContextValue {
|
|
appLocale: AppLocale
|
|
languageSettings: SystemLanguageSettings
|
|
languageSettingsLoaded: boolean
|
|
setLanguageSettings: (settings: SystemLanguageSettings) => void
|
|
}
|
|
|
|
const AppI18nContext = createContext<AppI18nContextValue | null>(null)
|
|
const LANGUAGE_SETTINGS_UPDATED_EVENT = "app://language-settings-updated"
|
|
|
|
function subscribeSystemLocale(onStoreChange: () => void) {
|
|
if (typeof window === "undefined") return () => {}
|
|
|
|
window.addEventListener("languagechange", onStoreChange)
|
|
return () => {
|
|
window.removeEventListener("languagechange", onStoreChange)
|
|
}
|
|
}
|
|
|
|
function getSystemLocaleSnapshot(): string {
|
|
return getSystemLocaleCandidates().join("|")
|
|
}
|
|
|
|
function getSystemLocaleServerSnapshot(): string {
|
|
return ""
|
|
}
|
|
|
|
function persistLanguageSettings(settings: SystemLanguageSettings) {
|
|
if (typeof window === "undefined") return
|
|
|
|
try {
|
|
window.localStorage.setItem(
|
|
LANGUAGE_SETTINGS_STORAGE_KEY,
|
|
JSON.stringify(settings)
|
|
)
|
|
} catch {
|
|
// Ignore write failures (e.g. disabled storage).
|
|
}
|
|
}
|
|
|
|
function persistLanguageCookies(
|
|
settings: SystemLanguageSettings,
|
|
appLocale: AppLocale
|
|
) {
|
|
if (typeof document === "undefined") return
|
|
|
|
const maxAge = 60 * 60 * 24 * 365
|
|
document.cookie = `${LANGUAGE_MODE_COOKIE_KEY}=${settings.mode}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
|
|
document.cookie = `${LANGUAGE_COOKIE_KEY}=${toIntlLocale(appLocale)}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
|
|
}
|
|
|
|
export function useAppI18n() {
|
|
const context = useContext(AppI18nContext)
|
|
if (!context) {
|
|
throw new Error("useAppI18n must be used within AppI18nProvider")
|
|
}
|
|
return context
|
|
}
|
|
|
|
interface AppI18nProviderProps {
|
|
children: React.ReactNode
|
|
initialLocale?: IntlLocale
|
|
initialMessages?: AbstractIntlMessages
|
|
}
|
|
|
|
export function AppI18nProvider({
|
|
children,
|
|
initialLocale = "en",
|
|
initialMessages,
|
|
}: AppI18nProviderProps) {
|
|
const initialAppLocale = fromIntlLocale(initialLocale)
|
|
const [languageSettings, setLanguageSettingsState] =
|
|
useState<SystemLanguageSettings>({
|
|
mode: "manual",
|
|
language: initialAppLocale,
|
|
})
|
|
const [languageSettingsLoaded, setLanguageSettingsLoaded] = useState(false)
|
|
const [messages, setMessages] = useState<AbstractIntlMessages>(
|
|
initialMessages ?? getFallbackMessages()
|
|
)
|
|
const [messagesLocale, setMessagesLocale] = useState<AppLocale>(
|
|
initialMessages ? initialAppLocale : "en"
|
|
)
|
|
|
|
const systemLocaleSnapshot = useSyncExternalStore(
|
|
subscribeSystemLocale,
|
|
getSystemLocaleSnapshot,
|
|
getSystemLocaleServerSnapshot
|
|
)
|
|
const systemLocaleCandidates = useMemo(
|
|
() => (systemLocaleSnapshot ? systemLocaleSnapshot.split("|") : []),
|
|
[systemLocaleSnapshot]
|
|
)
|
|
|
|
const setLanguageSettings = useCallback(
|
|
(settings: SystemLanguageSettings) => {
|
|
const normalized = normalizeLanguageSettings(settings)
|
|
setLanguageSettingsState(normalized)
|
|
persistLanguageSettings(normalized)
|
|
},
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return
|
|
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key !== LANGUAGE_SETTINGS_STORAGE_KEY || !event.newValue) return
|
|
|
|
try {
|
|
const next = normalizeLanguageSettings(
|
|
JSON.parse(event.newValue) as SystemLanguageSettings
|
|
)
|
|
setLanguageSettingsState(next)
|
|
} catch {
|
|
// Ignore malformed storage payloads.
|
|
}
|
|
}
|
|
|
|
window.addEventListener("storage", onStorage)
|
|
|
|
let unlisten: (() => void) | null = null
|
|
let cancelled = false
|
|
|
|
void import("@tauri-apps/api/event")
|
|
.then(({ listen }) =>
|
|
listen<SystemLanguageSettings>(
|
|
LANGUAGE_SETTINGS_UPDATED_EVENT,
|
|
(event) => {
|
|
if (cancelled) return
|
|
setLanguageSettings(event.payload)
|
|
}
|
|
)
|
|
)
|
|
.then((dispose) => {
|
|
if (cancelled) {
|
|
dispose()
|
|
return
|
|
}
|
|
unlisten = dispose
|
|
})
|
|
.catch(() => {
|
|
// Ignore when running in non-tauri environment.
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
window.removeEventListener("storage", onStorage)
|
|
if (unlisten) {
|
|
unlisten()
|
|
}
|
|
}
|
|
}, [setLanguageSettings])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
getSystemLanguageSettings()
|
|
.then((settings) => {
|
|
if (cancelled) return
|
|
setLanguageSettings(settings)
|
|
})
|
|
.catch((err) => {
|
|
console.error("[i18n] load language settings failed:", err)
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setLanguageSettingsLoaded(true)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [setLanguageSettings])
|
|
|
|
const appLocale = useMemo(
|
|
() => resolveAppLocale(languageSettings, systemLocaleCandidates),
|
|
[languageSettings, systemLocaleCandidates]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!languageSettingsLoaded) return
|
|
persistLanguageCookies(languageSettings, appLocale)
|
|
}, [appLocale, languageSettings, languageSettingsLoaded])
|
|
|
|
useEffect(() => {
|
|
if (appLocale === messagesLocale) {
|
|
return
|
|
}
|
|
let cancelled = false
|
|
|
|
getMessagesForLocale(appLocale)
|
|
.then((nextMessages) => {
|
|
if (!cancelled) {
|
|
setMessages(nextMessages)
|
|
setMessagesLocale(appLocale)
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("[i18n] load locale messages failed:", err)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [appLocale, messagesLocale])
|
|
|
|
const localeReady = appLocale === messagesLocale
|
|
const appReady = languageSettingsLoaded && localeReady
|
|
const activeIntlLocale = toIntlLocale(messagesLocale)
|
|
|
|
useEffect(() => {
|
|
document.documentElement.lang = activeIntlLocale
|
|
}, [activeIntlLocale])
|
|
|
|
const contextValue = useMemo<AppI18nContextValue>(
|
|
() => ({
|
|
appLocale,
|
|
languageSettings,
|
|
languageSettingsLoaded,
|
|
setLanguageSettings,
|
|
}),
|
|
[appLocale, languageSettings, languageSettingsLoaded, setLanguageSettings]
|
|
)
|
|
|
|
return (
|
|
<AppI18nContext.Provider value={contextValue}>
|
|
<NextIntlClientProvider locale={activeIntlLocale} messages={messages}>
|
|
{appReady ? children : <AppBootLoading />}
|
|
</NextIntlClientProvider>
|
|
</AppI18nContext.Provider>
|
|
)
|
|
}
|