Adds front-end set-up

This commit is contained in:
2026-06-20 17:04:17 +02:00
parent efd1569c26
commit 7dfc3a9692
62 changed files with 6875 additions and 3 deletions
+55
View File
@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheck,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
changeLanguage,
LANGUAGE_LABELS,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from '@/i18n/config';
/** Language selector for the topbar; lazy-loads the chosen locale (Q4-B). */
export function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const current = (i18n.resolvedLanguage ?? 'en') as SupportedLanguage;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('userMenu.language')}
data-testid="language-switcher-button"
>
<Languages />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('userMenu.language')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{SUPPORTED_LANGUAGES.map((lng) => (
<DropdownMenuItem
key={lng}
onSelect={() => {
void changeLanguage(lng);
}}
data-testid={`language-option-${lng}`}
>
{LANGUAGE_LABELS[lng]}
<DropdownMenuCheck checked={current === lng} />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+54
View File
@@ -0,0 +1,54 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from './locales/en/translation.json';
export const SUPPORTED_LANGUAGES = ['en', 'nl'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const LANGUAGE_LABELS: Record<SupportedLanguage, string> = {
en: 'English',
nl: 'Nederlands',
};
// English (the fallback) is bundled eagerly so the UI never flashes raw keys.
// Other locales are lazy-loaded on demand (Q4-B / NFR-U1-05).
const loaded = new Set<string>(['en']);
async function loadLocale(lng: string): Promise<void> {
if (loaded.has(lng) || !SUPPORTED_LANGUAGES.includes(lng as SupportedLanguage)) {
return;
}
const module = await import(`./locales/${lng}/translation.json`);
i18n.addResourceBundle(lng, 'translation', module.default, true, true);
loaded.add(lng);
}
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslation },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES as unknown as string[],
nonExplicitSupportedLngs: true,
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
react: { useSuspense: false },
});
// Ensure the detected language is loaded after init.
void loadLocale(i18n.resolvedLanguage ?? 'en');
/** Lazy-load the target locale, then switch to it (persisted by the detector). */
export async function changeLanguage(lng: SupportedLanguage): Promise<void> {
await loadLocale(lng);
await i18n.changeLanguage(lng);
}
export default i18n;
@@ -0,0 +1,42 @@
{
"common": {
"appName": "SlpModularCms",
"loading": "Loading…",
"cancel": "Cancel",
"save": "Save",
"retry": "Retry"
},
"nav": {
"dashboard": "Dashboard",
"users": "Users",
"cms": "CMS"
},
"login": {
"title": "Sign in",
"subtitle": "Sign in to your SlpModularCms account",
"email": "Email",
"emailPlaceholder": "you@example.com",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"errors": {
"emailRequired": "Email is required",
"emailInvalid": "Enter a valid email address",
"passwordRequired": "Password is required",
"invalidCredentials": "Invalid email or password",
"generic": "Something went wrong. Please try again."
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {{name}}",
"placeholder": "Your dashboard widgets will appear here."
},
"userMenu": {
"language": "Language",
"logout": "Sign out"
},
"errors": {
"network": "Unable to reach the server. Check your connection and try again."
}
}
@@ -0,0 +1,42 @@
{
"common": {
"appName": "SlpModularCms",
"loading": "Laden…",
"cancel": "Annuleren",
"save": "Opslaan",
"retry": "Opnieuw"
},
"nav": {
"dashboard": "Dashboard",
"users": "Gebruikers",
"cms": "CMS"
},
"login": {
"title": "Inloggen",
"subtitle": "Log in op je SlpModularCms-account",
"email": "E-mail",
"emailPlaceholder": "jij@voorbeeld.nl",
"password": "Wachtwoord",
"submit": "Inloggen",
"submitting": "Bezig met inloggen…",
"errors": {
"emailRequired": "E-mail is verplicht",
"emailInvalid": "Voer een geldig e-mailadres in",
"passwordRequired": "Wachtwoord is verplicht",
"invalidCredentials": "Ongeldige e-mail of wachtwoord",
"generic": "Er is iets misgegaan. Probeer het opnieuw."
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welkom terug, {{name}}",
"placeholder": "Je dashboard-widgets verschijnen hier."
},
"userMenu": {
"language": "Taal",
"logout": "Uitloggen"
},
"errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw."
}
}