Files
ProxMenux/AppImage/lib/i18n/provider.tsx
T
MacRimi f2420d9978 i18n polish + Swedish wired + roadmap slim + AppImage 1.2.4.1-beta
## i18n

- Swedish (sv) wired end-to-end: `MESSAGE_CATALOG` in `provider.tsx` now imports `sv/common.json`; `SUPPORTED_LANGUAGES` reordered as English + alphabetical native and all eight locales marked complete.
- Settings → Interface language: the per-locale status cards grid is retired now that all eight locales are populated end-to-end; the dropdown selector is the only control.
- CLI change-language dialog (`scripts/menus/config_menu.sh`) picks up Swedish and matches the Monitor ordering.
- Spanish catalog: label polish across the dashboard — *VM y LXC activos* / *11 VMs* / *11 detenidos*, LXC modal tabs shortened to *App / Actualiz. / Montajes / Copias*, *Limpiar / Cerrar* replacing the wrong *Claro / Cerca* in terminal controls, *En ejecución / Ejecutar* replacing the literal *Correr*.
- Cross-locale normalisation across ES / DE / FR / IT / PT / SK / SV: *OK* / *Proxmox System Dashboard* / *Warn* labels stay consistent, *AVG / MAX / MIN* kept verbatim as universal abbreviations so the stats row no longer overflows.
- Tailscale brand preserved verbatim across ES / FR / IT / PT — previous machine-translated variants like *"escala trasera"* / *"à grande échelle"* restored.

## Docs

- `ROADMAP.md` rewritten as a short invitation to the [ProxMenux Roadmap project board](https://github.com/users/MacRimi/projects/1). `ROADMAP.es.md` removed — it mirrored the same outdated version-milestone plan and the docs site keeps the Spanish surface for users who need it.
- `release.md` annotated with nine pending sections for the v1.2.4.1 beta cycle.

## AppImage

- `ProxMenux-1.2.4.1-beta.AppImage` replaces the previous `ProxMenux-1.2.4.AppImage`. Sidecar `ProxMenux-Monitor.AppImage.sha256` regenerated.
2026-08-10 13:23:26 +02:00

136 lines
4.0 KiB
TypeScript

"use client"
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
import enMessages from "../../messages/en/common.json"
import deMessages from "../../messages/de/common.json"
import esMessages from "../../messages/es/common.json"
import frMessages from "../../messages/fr/common.json"
import itMessages from "../../messages/it/common.json"
import ptMessages from "../../messages/pt/common.json"
import skMessages from "../../messages/sk/common.json"
import svMessages from "../../messages/sv/common.json"
import {
DEFAULT_LANGUAGE,
LANGUAGE_STORAGE_KEY,
type LanguageCode,
SUPPORTED_LANGUAGES,
detectBrowserLanguage,
isSupportedLanguage,
} from "./languages"
type MessageTree = Record<string, unknown>
type TranslationParams = Record<string, string | number>
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
en: enMessages as MessageTree,
de: deMessages as MessageTree,
es: esMessages as MessageTree,
fr: frMessages as MessageTree,
it: itMessages as MessageTree,
pt: ptMessages as MessageTree,
sk: skMessages as MessageTree,
sv: svMessages as MessageTree,
}
interface I18nContextValue {
language: LanguageCode
setLanguage: (language: LanguageCode) => void
t: (key: string, params?: TranslationParams) => string
}
const I18nContext = createContext<I18nContextValue | null>(null)
function getInitialLanguage(): LanguageCode {
if (typeof window === "undefined") return DEFAULT_LANGUAGE
try {
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
if (isSupportedLanguage(stored)) return stored
} catch {
// localStorage may be unavailable in private browsing.
}
return detectBrowserLanguage()
}
function getMessage(messages: MessageTree, key: string): string | undefined {
const value = key.split(".").reduce<unknown>((cursor, segment) => {
if (!cursor || typeof cursor !== "object") return undefined
return (cursor as Record<string, unknown>)[segment]
}, messages)
return typeof value === "string" ? value : undefined
}
function interpolate(template: string, params?: TranslationParams): string {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name) => {
const value = params[name]
return value === undefined ? match : String(value)
})
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState<LanguageCode>(DEFAULT_LANGUAGE)
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
setLanguageState(getInitialLanguage())
setIsHydrated(true)
}, [])
useEffect(() => {
if (!isHydrated) return
document.documentElement.lang = language
try {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
} catch {
// Best-effort; the in-memory language still works for this session.
}
}, [isHydrated, language])
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key === LANGUAGE_STORAGE_KEY && isSupportedLanguage(event.newValue)) {
setLanguageState(event.newValue)
}
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
}, [])
const setLanguage = useCallback((nextLanguage: LanguageCode) => {
setLanguageState(nextLanguage)
}, [])
const t = useCallback(
(key: string, params?: TranslationParams) => {
const localized = getMessage(MESSAGE_CATALOG[language], key)
const fallback = getMessage(MESSAGE_CATALOG.en, key)
return interpolate(localized ?? fallback ?? key, params)
},
[language],
)
const value = useMemo<I18nContextValue>(() => ({ language, setLanguage, t }), [language, setLanguage, t])
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n() {
const context = useContext(I18nContext)
if (!context) {
throw new Error("useI18n must be used within I18nProvider")
}
return context
}
export function useT() {
return useI18n().t
}
export { SUPPORTED_LANGUAGES }