diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php index 3f375a4..6de2b1f 100644 --- a/core/lib/Controllers/InitController.php +++ b/core/lib/Controllers/InitController.php @@ -2,14 +2,15 @@ namespace KTXC\Controllers; +use KTXC\Http\Request\Request; use KTXC\Http\Response\JsonResponse; +use KTXC\L10N\LocaleResolver; use KTXC\Module\ModuleManager; use KTXC\Security\Authorization\PermissionChecker; use KTXC\Service\UserAccountsService; use KTXC\SessionIdentity; use KTXF\Controller\ControllerAbstract; use KTXC\SessionTenant; -use KTXF\Module\ModuleBrowserInterface; use KTXF\Routing\Attributes\AuthenticatedRoute; class InitController extends ControllerAbstract @@ -20,10 +21,11 @@ class InitController extends ControllerAbstract private readonly ModuleManager $moduleManager, private readonly UserAccountsService $userService, private readonly PermissionChecker $permissionChecker, + private readonly LocaleResolver $localeResolver, ) {} #[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])] - public function index(): JsonResponse { + public function index(Request $request): JsonResponse { $configuration = []; @@ -43,6 +45,13 @@ class InitController extends ControllerAbstract } } + // localization + $configuration['l10n'] = [ + 'locale' => $this->localeResolver->resolve($request), + 'fallback' => LocaleResolver::FALLBACK, + 'available' => $this->localeResolver->available(), + ]; + // tenant $configuration['tenant'] = [ 'id' => $this->tenant->identifier(), diff --git a/core/lib/L10N/LocaleResolver.php b/core/lib/L10N/LocaleResolver.php new file mode 100644 index 0000000..492c5cd --- /dev/null +++ b/core/lib/L10N/LocaleResolver.php @@ -0,0 +1,71 @@ + Accept-Language + * negotiation -> fallback. Tenant default slots in between once tenant + * settings expose one (Phase 3). + */ +class LocaleResolver +{ + public const FALLBACK = 'en'; + public const SETTING_KEY = 'core.locale'; + + public function __construct( + private readonly UserAccountsService $userService, + #[Inject('rootDir')] private readonly string $rootDir, + ) {} + + /** + * Locales with a core catalog on disk (public/l10n/*.json), always + * including the fallback. This backs the language picker, so a locale + * is only offered when it has at least core-shell coverage. + * + * @return string[] + */ + public function available(): array + { + $locales = [self::FALLBACK]; + foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) { + $locales[] = basename($file, '.json'); + } + $locales = array_values(array_unique($locales)); + sort($locales); + return $locales; + } + + public function resolve(Request $request): string + { + $available = $this->available(); + + // 1. explicit user preference + $settings = $this->userService->fetchSettings([self::SETTING_KEY]); + $preference = $settings[self::SETTING_KEY] ?? null; + if (is_string($preference) && in_array($preference, $available, true)) { + return $preference; + } + + // 2. Accept-Language negotiation. getPreferredLanguage() returns the + // first candidate when nothing matches, so the fallback leads the + // list; it also normalizes to underscore form (pt_BR) while catalogs + // use BCP-47 hyphens. + $candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK])); + $negotiated = $request->getPreferredLanguage($candidates); + if ($negotiated !== null) { + $negotiated = str_replace('_', '-', $negotiated); + if (in_array($negotiated, $available, true)) { + return $negotiated; + } + } + + // 3. fallback + return self::FALLBACK; + } +} diff --git a/core/src/composables/index.ts b/core/src/composables/index.ts index 700784e..5a99236 100644 --- a/core/src/composables/index.ts +++ b/core/src/composables/index.ts @@ -3,4 +3,6 @@ */ export { useClipboard } from './useClipboard' +export { useL10n, t } from './useL10n' +export type { L10N, TranslateParams } from './useL10n' export { useUser } from './useUser' diff --git a/core/src/composables/useL10n.ts b/core/src/composables/useL10n.ts new file mode 100644 index 0000000..1396751 --- /dev/null +++ b/core/src/composables/useL10n.ts @@ -0,0 +1,42 @@ +/** + * Translation composable over the shared i18n runtime. + * This is the only surface components and modules use; vue-i18n itself + * stays confined to the plugin. + */ +import { i18n } from '@KTXC/l10n/runtime'; + +export type TranslateParams = Record; + +export interface L10N { + /** + * Translate a catalog key, rendering the inline English default when the + * key is absent from every loaded catalog. Keys are namespaced with the + * handle bound at useL10n(); the `en` catalog is generated from these + * defaults by scripts/l10n-extract.mjs — keep both literal. + */ + t: (key: string, defaultText: string, params?: TranslateParams) => string; + /** Locale-aware date/time formatting (Intl-backed). */ + d: typeof i18n.global.d; + /** Locale-aware number formatting (Intl-backed). */ + n: typeof i18n.global.n; +} + +/** + * Translate a fully-qualified catalog key (one that already carries its + * namespace, e.g. an integration entry's `l10n` key, which the integration + * store prefixes with the module handle). `defaultText` is rendered when the + * key is undefined or absent from every loaded catalog. Such keys are + * dynamic, so their catalog entries live in the module's hand-maintained + * en.manual.json rather than the extracted catalog. + */ +export function t(key: string | undefined, defaultText?: string, params: TranslateParams = {}): string { + if (!key) return defaultText ?? ''; + return i18n.global.t(key, params, { default: defaultText ?? key }); +} + +export function useL10n(namespace: string): L10N { + const scoped = (key: string, defaultText: string, params: TranslateParams = {}): string => + t(`${namespace}.${key}`, defaultText, params); + + return { t: scoped, d: i18n.global.d, n: i18n.global.n }; +} diff --git a/core/src/l10n/de.json b/core/src/l10n/de.json new file mode 100644 index 0000000..0058712 --- /dev/null +++ b/core/src/l10n/de.json @@ -0,0 +1,24 @@ +{ + "header": { + "searchPlaceholder": "Hier suchen.." + }, + "systemMenu": { + "administration": "Administration", + "applications": "Anwendungen", + "personalSettings": "Persönliche Einstellungen", + "toggleAdmin": "Admin", + "toggleApps": "Apps", + "toggleSettings": "Einstellungen" + }, + "notifications": { + "markAllRead": "Alle als gelesen markieren", + "title": "Benachrichtigungen", + "viewAll": "Alle anzeigen" + }, + "userMenu": { + "darkMode": "Dunkles Design", + "lightMode": "Helles Design", + "logout": "Abmelden", + "settings": "Einstellungen" + } +} diff --git a/core/src/l10n/en.json b/core/src/l10n/en.json new file mode 100644 index 0000000..8158294 --- /dev/null +++ b/core/src/l10n/en.json @@ -0,0 +1,14 @@ +{ + "systemMenu": { + "adminSettings": "System", + "apps": "Applications", + "personalSettings": "Settings", + "userSettings": "Settings" + }, + "userMenu": { + "darkMode": "Dark Mode", + "lightMode": "Light Mode", + "logout": "Logout", + "settings": "Settings" + } +} diff --git a/core/src/l10n/runtime.ts b/core/src/l10n/runtime.ts new file mode 100644 index 0000000..2a002bc --- /dev/null +++ b/core/src/l10n/runtime.ts @@ -0,0 +1,33 @@ +import { createI18n } from 'vue-i18n'; +import { en as vuetifyEn, de as vuetifyDe } from 'vuetify/locale'; + +export const FALLBACK_LOCALE = 'en'; + +/** + * The single vue-i18n instance shared by the core shell and all modules. + */ +export const i18n = createI18n({ + legacy: false, + globalInjection: false, + locale: FALLBACK_LOCALE, + fallbackLocale: FALLBACK_LOCALE, + missingWarn: import.meta.env.DEV, + fallbackWarn: false, + messages: { + // Vuetify component strings ($vuetify.*) ride along in the same + // instance, consumed through the createVueI18nAdapter in the vuetify + // plugin. Statically imported: adding a locale means adding it here. + en: { $vuetify: vuetifyEn }, + de: { $vuetify: vuetifyDe }, + }, +}); + +/** Merge a catalog's messages under a namespace for the given locale. */ +export function mergeCatalog(locale: string, namespace: string, messages: Record): void { + i18n.global.mergeLocaleMessage(locale, { [namespace]: messages }); +} + +/** Switch the active locale of the shared instance. */ +export function applyLocale(locale: string): void { + i18n.global.locale.value = locale; +} diff --git a/core/src/layouts/menus/LayoutSystemMenu.vue b/core/src/layouts/menus/LayoutSystemMenu.vue index d71ea8a..c7a6a58 100644 --- a/core/src/layouts/menus/LayoutSystemMenu.vue +++ b/core/src/layouts/menus/LayoutSystemMenu.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { useLayoutStore } from '@KTXC/stores/layoutStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { useL10n } from '@KTXC/composables/useL10n'; import Logo from '@KTXC/layouts/logo/LogoDark.vue'; import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue'; import SystemMenuGroupDynamic from './LayoutSystemMenuGroupDynamic.vue'; @@ -9,6 +10,7 @@ import SystemMenuItem from './LayoutSystemMenuItem.vue'; const layoutStore = useLayoutStore(); const integrationStore = useIntegrationStore(); +const { t } = useL10n('core'); // Get all entries based on current menu mode const menuEntries = computed(() => { @@ -29,23 +31,23 @@ const menuModeInfo = computed(() => { case 'user-settings': return { icon: 'mdi-account-cog', - label: 'Personal Settings', - toggleLabel: 'Admin', + label: t('systemMenu.personalSettings', 'Settings'), + toggleLabel: t('systemMenu.adminSettings', 'System'), toggleIcon: 'mdi-shield-crown', }; case 'admin-settings': return { icon: 'mdi-shield-crown', - label: 'Administration', - toggleLabel: 'Apps', + label: t('systemMenu.adminSettings', 'System'), + toggleLabel: t('systemMenu.apps', 'Applications'), toggleIcon: 'mdi-view-dashboard', }; case 'apps': default: return { icon: 'mdi-view-dashboard', - label: 'Applications', - toggleLabel: 'Settings', + label: t('systemMenu.apps', 'Applications'), + toggleLabel: t('systemMenu.userSettings', 'Settings'), toggleIcon: 'mdi-account-cog', }; } diff --git a/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue b/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue index af1dc4c..81170f5 100644 --- a/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue +++ b/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue @@ -1,5 +1,6 @@