Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
50 changes: 28 additions & 22 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand All @@ -45,6 +47,8 @@ const SwaggerDocs = lazy(() => import('@/modules/core/views/SwaggerDocs').then((
const RouteLoading = () => <div className="p-20 flex justify-center"><Spinner /></div>;

const StatusRedirect = () => {
const { t } = useTranslation('status');

useEffect(() => {
if (typeof window !== 'undefined') {
window.location.replace(STATUS_PAGE_URL);
Expand All @@ -54,15 +58,15 @@ const StatusRedirect = () => {
return (
<main className="min-h-[60vh] flex items-center justify-center bg-slate-50 px-6 dark:bg-modtale-dark">
<div className="max-w-md rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm dark:border-white/10 dark:bg-slate-900">
<h1 className="text-xl font-black text-slate-950 dark:text-white">Opening Modtale Status</h1>
<h1 className="text-xl font-black text-slate-950 dark:text-white">{t('title')}</h1>
<p className="mt-2 text-sm font-medium leading-6 text-slate-500 dark:text-slate-400">
Redirecting to {STATUS_PAGE_URL}.
{t('redirecting', { url: STATUS_PAGE_URL })}
</p>
<a
href={STATUS_PAGE_URL}
className="mt-5 inline-flex h-10 items-center justify-center rounded-lg bg-modtale-accent px-4 text-sm font-bold text-white transition hover:bg-blue-600"
>
Open Status
{t('open')}
</a>
</div>
</main>
Expand Down Expand Up @@ -368,25 +372,27 @@ const AppContent: React.FC = () => {

export const App: React.FC<any> = ({ initialPath, ssrData }) => {
return (
<SSRProvider data={ssrData || null} initialPath={initialPath || '/'}>
<HelmetProvider>
<MobileProvider>
<ExternalLinkProvider>
<ToastProvider>
{import.meta.env.SSR ? (
<StaticRouter location={initialPath || "/"}>
<AppContent />
</StaticRouter>
) : (
<BrowserRouter>
<AppContent />
</BrowserRouter>
)}
</ToastProvider>
</ExternalLinkProvider>
</MobileProvider>
</HelmetProvider>
</SSRProvider>
<LocalizationProvider>
<SSRProvider data={ssrData || null} initialPath={initialPath || '/'}>
<HelmetProvider>
<MobileProvider>
<ExternalLinkProvider>
<ToastProvider>
{import.meta.env.SSR ? (
<StaticRouter location={initialPath || "/"}>
<AppContent />
</StaticRouter>
) : (
<BrowserRouter>
<AppContent />
</BrowserRouter>
)}
</ToastProvider>
</ExternalLinkProvider>
</MobileProvider>
</HelmetProvider>
</SSRProvider>
</LocalizationProvider>
);
};
export default App;
12 changes: 7 additions & 5 deletions frontend/src/components/ui/error/NotFound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="min-h-[60vh] flex flex-col items-center justify-center p-4 text-center">
<Helmet>
<title>404 - Page Not Found | Modtale</title>
<title>{t('notFound.documentTitle')}</title>
<meta name="prerender-status-code" content="404" />
</Helmet>

Expand All @@ -16,21 +18,21 @@ const NotFound: React.FC = () => {
</div>

<h1 className="text-4xl font-black text-slate-900 dark:text-white mb-4">
Page Not Found
{t('notFound.title')}
</h1>

<p className="text-lg text-slate-600 dark:text-slate-400 max-w-md mb-8">
The mod, modpack, or page you are looking for doesn't exist or has been removed.
{t('notFound.description')}
</p>

<Link
to="/"
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-colors shadow-lg shadow-blue-600/20"
>
Return Home
{t('notFound.returnHome')}
</Link>
</div>
);
};

export default NotFound;
export default NotFound;
27 changes: 27 additions & 0 deletions frontend/src/i18n/LanguageSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<label className={`inline-flex items-center gap-2 text-sm ${className}`}>
<span>{t('language.label')}</span>
<select
value={locale}
onChange={event => void setLocale(event.target.value as SupportedLocale)}
aria-label={t('language.label')}
className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-slate-700 dark:border-white/10 dark:bg-slate-900 dark:text-slate-200"
>
{supportedLocales.map(value => (
<option key={value} value={value}>{localeMetadata[value].nativeName}</option>
))}
</select>
</label>
);
};
63 changes: 63 additions & 0 deletions frontend/src/i18n/LocalizationProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
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<LocalizationContextValue | null>(null);

export const LocalizationProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [instance] = useState(() => createAppI18n(DEFAULT_LOCALE));
const [locale, setLocaleState] = useState<SupportedLocale>(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<LocalizationContextValue>(() => ({
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 (
<I18nextProvider i18n={instance}>
<LocalizationContext.Provider value={value}>{children}</LocalizationContext.Provider>
</I18nextProvider>
);
};

export const useLocalization = () => {
const context = useContext(LocalizationContext);
if (!context) throw new Error('useLocalization must be used within LocalizationProvider');
return context;
};
13 changes: 13 additions & 0 deletions frontend/src/i18n/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Web localization

User-facing copy lives in `locales/<locale>.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.
Loading
Loading