From 4272c84c83da8fa822c0d5776ebdce5a28e6d032 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:11:29 -0400 Subject: [PATCH] Build web localization foundation --- frontend/package-lock.json | 93 ++++++++++++++++++- frontend/package.json | 2 + frontend/src/App.tsx | 50 +++++----- frontend/src/components/ui/error/NotFound.tsx | 12 ++- frontend/src/i18n/LanguageSelector.tsx | 27 ++++++ frontend/src/i18n/LocalizationProvider.tsx | 63 +++++++++++++ frontend/src/i18n/README.md | 13 +++ frontend/src/i18n/config.ts | 43 +++++++++ frontend/src/i18n/i18n.ts | 35 +++++++ frontend/src/i18n/i18next.d.ts | 9 ++ frontend/src/i18n/index.ts | 3 + frontend/src/i18n/locales/en.ts | 82 ++++++++++++++++ frontend/src/i18n/resources.ts | 11 +++ frontend/src/layouts/BaseLayout.astro | 28 +++++- .../src/modules/core/components/Footer.tsx | 44 +++++---- .../src/modules/core/components/Navbar.tsx | 70 +++++++------- .../project/components/ProjectLayout.tsx | 22 +++-- frontend/tests/i18n/config.test.ts | 36 +++++++ frontend/tests/setup.ts | 26 ++++++ 19 files changed, 574 insertions(+), 95 deletions(-) create mode 100644 frontend/src/i18n/LanguageSelector.tsx create mode 100644 frontend/src/i18n/LocalizationProvider.tsx create mode 100644 frontend/src/i18n/README.md create mode 100644 frontend/src/i18n/config.ts create mode 100644 frontend/src/i18n/i18n.ts create mode 100644 frontend/src/i18n/i18next.d.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.ts create mode 100644 frontend/src/i18n/resources.ts create mode 100644 frontend/tests/i18n/config.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e167562f8..10e40a365 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "astro": "^7.2.10", "axios": "^1.20.0", + "i18next": "^25.10.10", "jszip": "^3.10.1", "lucide-react": "^1.39.0", "mermaid": "^11.17.2", @@ -24,6 +25,7 @@ "react-dropzone": "^20.1.1", "react-easy-crop": "^6.2.3", "react-helmet-async": "^3.0.0", + "react-i18next": "^16.6.6", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.3", "react-syntax-highlighter": "^16.1.1", @@ -722,9 +724,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6166,6 +6168,15 @@ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6225,6 +6236,37 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8472,6 +8514,33 @@ "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-i18next": { + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.9", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -9775,6 +9844,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10022,6 +10100,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/volar-service-css": { "version": "0.0.70", "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", diff --git a/frontend/package.json b/frontend/package.json index 141914d19..601e5abbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "astro": "^7.2.10", "axios": "^1.20.0", + "i18next": "^25.10.10", "jszip": "^3.10.1", "lucide-react": "^1.39.0", "mermaid": "^11.17.2", @@ -32,6 +33,7 @@ "react-dropzone": "^20.1.1", "react-easy-crop": "^6.2.3", "react-helmet-async": "^3.0.0", + "react-i18next": "^16.6.6", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.3", "react-syntax-highlighter": "^16.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5213e38ee..690b7378a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,8 @@ import { STATUS_PAGE_URL } from '@/utils/status'; import type { Classification } from '@/data/categories'; import { normalizeUser } from '@/utils/users'; import { clearPendingSignInMethod, completeSignInMethod } from '@/modules/auth/api/authClient'; +import { LocalizationProvider } from '@/i18n'; +import { useTranslation } from 'react-i18next'; const StatusModal = lazy(() => import('@/components/ui/StatusModal').then((module) => ({ default: module.StatusModal }))); const Onboarding = lazy(() => import('@/modules/user/components/Onboarding').then((module) => ({ default: module.Onboarding }))); @@ -45,6 +47,8 @@ const SwaggerDocs = lazy(() => import('@/modules/core/views/SwaggerDocs').then(( const RouteLoading = () =>
; const StatusRedirect = () => { + const { t } = useTranslation('status'); + useEffect(() => { if (typeof window !== 'undefined') { window.location.replace(STATUS_PAGE_URL); @@ -54,15 +58,15 @@ const StatusRedirect = () => { return (
-

Opening Modtale Status

+

{t('title')}

- Redirecting to {STATUS_PAGE_URL}. + {t('redirecting', { url: STATUS_PAGE_URL })}

- Open Status + {t('open')}
@@ -368,25 +372,27 @@ const AppContent: React.FC = () => { export const App: React.FC = ({ initialPath, ssrData }) => { return ( - - - - - - {import.meta.env.SSR ? ( - - - - ) : ( - - - - )} - - - - - + + + + + + + {import.meta.env.SSR ? ( + + + + ) : ( + + + + )} + + + + + + ); }; export default App; diff --git a/frontend/src/components/ui/error/NotFound.tsx b/frontend/src/components/ui/error/NotFound.tsx index 6d673c176..8c5cf8eca 100644 --- a/frontend/src/components/ui/error/NotFound.tsx +++ b/frontend/src/components/ui/error/NotFound.tsx @@ -2,12 +2,14 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; import { AlertCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; const NotFound: React.FC = () => { + const { t } = useTranslation('errors'); return (
- 404 - Page Not Found | Modtale + {t('notFound.documentTitle')} @@ -16,21 +18,21 @@ const NotFound: React.FC = () => {

- Page Not Found + {t('notFound.title')}

- The mod, modpack, or page you are looking for doesn't exist or has been removed. + {t('notFound.description')}

- Return Home + {t('notFound.returnHome')} ); }; -export default NotFound; \ No newline at end of file +export default NotFound; diff --git a/frontend/src/i18n/LanguageSelector.tsx b/frontend/src/i18n/LanguageSelector.tsx new file mode 100644 index 000000000..4eeffffb7 --- /dev/null +++ b/frontend/src/i18n/LanguageSelector.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { localeMetadata, supportedLocales, type SupportedLocale } from './config'; +import { useLocalization } from './LocalizationProvider'; + +export const LanguageSelector: React.FC<{ className?: string }> = ({ className = '' }) => { + const { t } = useTranslation('common'); + const { locale, setLocale } = useLocalization(); + + if (supportedLocales.length < 2) return null; + + return ( + + ); +}; diff --git a/frontend/src/i18n/LocalizationProvider.tsx b/frontend/src/i18n/LocalizationProvider.tsx new file mode 100644 index 000000000..f81026024 --- /dev/null +++ b/frontend/src/i18n/LocalizationProvider.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { I18nextProvider } from 'react-i18next'; +import { + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + detectPreferredLocale, + localeMetadata, + type SupportedLocale +} from './config'; +import { createAppI18n, ensureLocaleLoaded } from './i18n'; + +type LocalizationContextValue = { + locale: SupportedLocale; + setLocale: (locale: SupportedLocale) => Promise; + formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string; + formatDate: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => string; + formatList: (values: string[], options?: Intl.ListFormatOptions) => string; +}; + +const LocalizationContext = createContext(null); + +export const LocalizationProvider: React.FC = ({ children }) => { + const [instance] = useState(() => createAppI18n(DEFAULT_LOCALE)); + const [locale, setLocaleState] = useState(DEFAULT_LOCALE); + + const setLocale = useCallback(async (nextLocale: SupportedLocale) => { + await ensureLocaleLoaded(instance, nextLocale); + setLocaleState(nextLocale); + if (typeof document !== 'undefined') { + document.documentElement.lang = nextLocale; + document.documentElement.dir = localeMetadata[nextLocale].direction; + } + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale); + } catch { + // A locale still applies for this session when persistence is unavailable. + } + }, [instance]); + + useEffect(() => { + void setLocale(detectPreferredLocale()); + }, [setLocale]); + + const value = useMemo(() => ({ + locale, + setLocale, + formatNumber: (number, options) => new Intl.NumberFormat(locale, options).format(number), + formatDate: (date, options) => new Intl.DateTimeFormat(locale, options).format(new Date(date)), + formatList: (values, options) => new Intl.ListFormat(locale, options).format(values) + }), [locale, setLocale]); + + return ( + + {children} + + ); +}; + +export const useLocalization = () => { + const context = useContext(LocalizationContext); + if (!context) throw new Error('useLocalization must be used within LocalizationProvider'); + return context; +}; diff --git a/frontend/src/i18n/README.md b/frontend/src/i18n/README.md new file mode 100644 index 000000000..9ea188cd9 --- /dev/null +++ b/frontend/src/i18n/README.md @@ -0,0 +1,13 @@ +# Web localization + +User-facing copy lives in `locales/.ts` and is accessed through `react-i18next`. English is the fallback and currently the only shipped locale. + +To add a language: + +1. Copy `locales/en.ts`, preserving its complete key structure. +2. Add the locale and its native display name/direction to `localeMetadata`. +3. Add its lazy loader in `resources.ts`. + +The language selector appears automatically once a second locale is registered. Locale selection is persisted under `modtale-locale`; otherwise the browser language is negotiated. The root provider updates both `lang` and `dir`, while `useLocalization` supplies locale-aware number, date, and list formatting. + +Keep dynamic values in interpolation variables, use i18next plural suffixes (`_one`, `_other`) for counts, and avoid assembling translated sentences from fragments. diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts new file mode 100644 index 000000000..ff63b06e7 --- /dev/null +++ b/frontend/src/i18n/config.ts @@ -0,0 +1,43 @@ +export const DEFAULT_LOCALE = 'en' as const; +export const LOCALE_STORAGE_KEY = 'modtale-locale'; + +export const localeMetadata = { + en: { nativeName: 'English', direction: 'ltr' } +} as const; + +export type SupportedLocale = keyof typeof localeMetadata; +export type TextDirection = (typeof localeMetadata)[SupportedLocale]['direction']; + +export const supportedLocales = Object.keys(localeMetadata) as SupportedLocale[]; + +export const normalizeLocale = (candidate?: string | null): SupportedLocale | null => { + if (!candidate) return null; + const normalized = candidate.trim().replace('_', '-').toLowerCase(); + if (!normalized) return null; + const exact = supportedLocales.find(locale => locale.toLowerCase() === normalized); + if (exact) return exact; + const base = normalized.split('-')[0]; + return supportedLocales.find(locale => locale.toLowerCase() === base) ?? null; +}; + +export const detectPreferredLocale = (): SupportedLocale => { + if (typeof window === 'undefined') return DEFAULT_LOCALE; + + try { + const stored = normalizeLocale(window.localStorage.getItem(LOCALE_STORAGE_KEY)); + if (stored) return stored; + } catch { + // Storage can be unavailable in privacy-restricted browsers. + } + + const candidates = [ + ...(window.navigator.languages ?? []), + window.navigator.language, + window.document.documentElement.lang + ]; + for (const candidate of candidates) { + const locale = normalizeLocale(candidate); + if (locale) return locale; + } + return DEFAULT_LOCALE; +}; diff --git a/frontend/src/i18n/i18n.ts b/frontend/src/i18n/i18n.ts new file mode 100644 index 000000000..f242b35fd --- /dev/null +++ b/frontend/src/i18n/i18n.ts @@ -0,0 +1,35 @@ +import { createInstance, type i18n } from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { DEFAULT_LOCALE, supportedLocales, type SupportedLocale } from './config'; +import { defaultResources, loadLocale } from './resources'; + +export const createAppI18n = (locale: SupportedLocale = DEFAULT_LOCALE): i18n => { + const instance = createInstance(); + void instance.use(initReactI18next).init({ + lng: locale, + fallbackLng: DEFAULT_LOCALE, + supportedLngs: supportedLocales, + resources: defaultResources, + defaultNS: 'common', + fallbackNS: 'common', + load: 'languageOnly', + cleanCode: true, + nonExplicitSupportedLngs: true, + returnNull: false, + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + showSupportNotice: false, + initImmediate: false + }); + return instance; +}; + +export const ensureLocaleLoaded = async (instance: i18n, locale: SupportedLocale) => { + if (!instance.hasResourceBundle(locale, 'common')) { + const resources = await loadLocale(locale); + Object.entries(resources).forEach(([namespace, messages]) => { + instance.addResourceBundle(locale, namespace, messages, true, true); + }); + } + await instance.changeLanguage(locale); +}; diff --git a/frontend/src/i18n/i18next.d.ts b/frontend/src/i18n/i18next.d.ts new file mode 100644 index 000000000..049b3d2d7 --- /dev/null +++ b/frontend/src/i18n/i18next.d.ts @@ -0,0 +1,9 @@ +import 'i18next'; +import en from './locales/en'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'common'; + resources: typeof en; + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 000000000..b6b0c238c --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,3 @@ +export * from './config'; +export * from './LanguageSelector'; +export * from './LocalizationProvider'; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts new file mode 100644 index 000000000..569c3e19e --- /dev/null +++ b/frontend/src/i18n/locales/en.ts @@ -0,0 +1,82 @@ +const en = { + common: { + actions: { + back: 'Back', + close: 'Close', + create: 'Create', + download: 'Download', + save: 'Save', + search: 'Search', + signIn: 'Sign in', + signOut: 'Sign out' + }, + language: { + label: 'Language' + }, + theme: 'Theme' + }, + navigation: { + browse: 'Browse', + mods: 'Mods', + allProjects: 'All Projects', + plugins: 'Plugins', + modpacks: 'Modpacks', + worlds: 'Worlds', + artAssets: 'Art Assets', + dataAssets: 'Data Assets', + api: 'API', + dashboard: 'Dashboard', + createProject: 'Create Project', + yourProfile: 'Your Profile', + profile: 'Profile', + following: 'Following', + userDashboard: 'User Dashboard', + adminPanel: 'Admin Panel', + signedInAs: 'Signed in as', + openMenu: 'Open navigation menu', + closeMenu: 'Close navigation menu' + }, + footer: { + discover: 'Discover', + resources: 'Resources', + community: 'Community', + apiDocs: 'API Docs', + status: 'Status', + terms: 'Terms of Service', + privacy: 'Privacy Policy', + copyright: '© {{year}} Modtale.', + description: { + default: 'The premier community repository for Hytale. Discover, download, and share Hytale mods, server plugins, worlds, art assets, data assets, and modpacks.', + plugins: 'The premier community repository for Hytale plugins. Discover, download, and share server plugins, admin tools, gameplay extensions, and supporting libraries.', + modpacks: 'The premier community repository for Hytale modpacks. Discover, download, and share curated Hytale modpacks, collections, and bundled project setups.', + worlds: 'The premier community repository for Hytale worlds. Discover, download, and share Hytale save files, maps, lobbies, schematics, and spawns.', + art: 'The premier community repository for Hytale art assets. Discover, download, and share Hytale models, textures, animations, and creator resources.', + data: 'The premier community repository for Hytale data assets. Discover, download, and share Hytale configs, loot tables, recipes, and data-driven files.' + } + }, + status: { + title: 'Opening Modtale Status', + redirecting: 'Redirecting to {{url}}.', + open: 'Open Status' + }, + project: { + bannerAlt: 'Project Banner', + iconAlt: 'Icon', + changeBanner: 'Change Banner', + uploadBanner: 'Upload Banner', + changeIcon: 'Change Icon', + recommendedBannerSize: 'Recommended: 1920x640', + shortRecommendedBannerSize: 'Rec: 1920x640', + recommendedIconSize: 'Rec: 512x512' + }, + errors: { + notFound: { + documentTitle: '404 - Page Not Found | Modtale', + title: 'Page Not Found', + description: "The mod, modpack, or page you are looking for doesn't exist or has been removed.", + returnHome: 'Return Home' + } + } +} as const; + +export default en; diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts new file mode 100644 index 000000000..c7136db3d --- /dev/null +++ b/frontend/src/i18n/resources.ts @@ -0,0 +1,11 @@ +import type { Resource } from 'i18next'; +import type { SupportedLocale } from './config'; +import en from './locales/en'; + +export const defaultResources: Resource = { en }; + +const localeLoaders: Record Promise<{ default: typeof en }>> = { + en: () => Promise.resolve({ default: en }) +}; + +export const loadLocale = async (locale: SupportedLocale) => (await localeLoaders[locale]()).default; diff --git a/frontend/src/layouts/BaseLayout.astro b/frontend/src/layouts/BaseLayout.astro index eed01d9bd..63d1c4d01 100644 --- a/frontend/src/layouts/BaseLayout.astro +++ b/frontend/src/layouts/BaseLayout.astro @@ -2,6 +2,7 @@ import '../index.css'; import { DEFAULT_SEO } from '../data/seo-constants'; import { BACKEND_URL } from '../utils/api'; +import { DEFAULT_LOCALE, localeMetadata, supportedLocales } from '../i18n/config'; interface Props { title?: string; @@ -50,10 +51,15 @@ const initialDataString = initialData ? JSON.stringify(initialData).replace(/ [locale, metadata.direction])) +}); --- - + @@ -108,6 +114,26 @@ const wikiBootstrapString = wikiBootstrap ? JSON.stringify(wikiBootstrap).replac } catch (e) {} })(); +