feat: localizations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -3,4 +3,6 @@
|
||||
*/
|
||||
|
||||
export { useClipboard } from './useClipboard'
|
||||
export { useL10n, t } from './useL10n'
|
||||
export type { L10N, TranslateParams } from './useL10n'
|
||||
export { useUser } from './useUser'
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"systemMenu": {
|
||||
"adminSettings": "System",
|
||||
"apps": "Applications",
|
||||
"personalSettings": "Settings",
|
||||
"userSettings": "Settings"
|
||||
},
|
||||
"userMenu": {
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"logout": "Logout",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>): 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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
|
||||
import { t } from '@KTXC/composables/useL10n';
|
||||
import NavItem from './LayoutSystemMenuItem.vue';
|
||||
|
||||
const props = defineProps<{ group: IntegrationGroup; level?: number }>();
|
||||
@@ -20,7 +21,7 @@ const props = defineProps<{ group: IntegrationGroup; level?: number }>();
|
||||
<v-icon v-if="group.icon" :icon="group.icon"></v-icon>
|
||||
</template>
|
||||
<!---Title -->
|
||||
<v-list-item-title class="mr-auto">{{ group.label }}</v-list-item-title>
|
||||
<v-list-item-title class="mr-auto">{{ t(group.l10n, group.label) }}</v-list-item-title>
|
||||
<!---If Caption-->
|
||||
<v-list-item-subtitle v-if="group.caption" class="text-caption mt-n1 hide-menu">
|
||||
{{ group.caption }}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { IntegrationGroup } from '@KTXC/types/integrationTypes';
|
||||
import { t } from '@KTXC/composables/useL10n';
|
||||
import LayoutSystemMenuItem from './LayoutSystemMenuItem.vue';
|
||||
|
||||
const props = defineProps<{ group: IntegrationGroup }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-list-subheader color="lightText" class="smallCap text-subtitle-2">{{ props.group.label }}</v-list-subheader>
|
||||
<v-list-subheader color="lightText" class="smallCap text-subtitle-2">{{ t(props.group.l10n, props.group.label) }}</v-list-subheader>
|
||||
<LayoutSystemMenuItem
|
||||
v-for="(item, i) in props.group.items"
|
||||
:key="i"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { IntegrationItem } from '@KTXC/types/integrationTypes';
|
||||
import { t } from '@KTXC/composables/useL10n';
|
||||
|
||||
const props = defineProps<{ item: IntegrationItem; level?: number }>();
|
||||
</script>
|
||||
@@ -20,7 +21,7 @@ const props = defineProps<{ item: IntegrationItem; level?: number }>();
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="props.item.icon" :icon="props.item.icon"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.label }}</v-list-item-title>
|
||||
<v-list-item-title>{{ t(item.l10n, item.label) }}</v-list-item-title>
|
||||
<!---If Caption-->
|
||||
<v-list-item-subtitle v-if="item.caption" class="text-caption mt-n1 hide-menu">
|
||||
{{ item.caption }}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
|
||||
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
|
||||
|
||||
const { t } = useL10n('core');
|
||||
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
@@ -74,7 +77,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="item.icon">{{ item.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-h6">{{ item.label }}</v-list-item-title>
|
||||
<v-list-item-title class="text-h6">{{ tGlobal(item.l10n, item.label) }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider v-if="profileMenuItems.length" class="my-2" />
|
||||
@@ -84,7 +87,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ isDarkMode ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-h6">{{ isDarkMode ? 'Light Mode' : 'Dark Mode' }}</v-list-item-title>
|
||||
<v-list-item-title class="text-h6">{{ isDarkMode ? t('userMenu.lightMode', 'Light Mode') : t('userMenu.darkMode', 'Dark Mode') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Go to Settings -->
|
||||
@@ -92,7 +95,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-cog-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-h6">Settings</v-list-item-title>
|
||||
<v-list-item-title class="text-h6">{{ t('userMenu.settings', 'Settings') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
@@ -102,7 +105,7 @@ const goToSettings = () => {
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-logout</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-h6">Logout</v-list-item-title>
|
||||
<v-list-item-title class="text-h6">{{ t('userMenu.logout', 'Logout') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</perfect-scrollbar>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createVuetify } from 'vuetify'
|
||||
import { VBtn } from 'vuetify/components/VBtn'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import { createVueI18nAdapter } from 'vuetify/locale/adapters/vue-i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@KTXC/l10n/runtime'
|
||||
import defaults from './defaults'
|
||||
import { icons } from './icons'
|
||||
import { themes } from './theme'
|
||||
@@ -17,6 +20,9 @@ export default createVuetify({
|
||||
},
|
||||
defaults,
|
||||
icons,
|
||||
locale: {
|
||||
adapter: createVueI18nAdapter({ i18n, useI18n }),
|
||||
},
|
||||
theme: {
|
||||
defaultTheme: 'light',
|
||||
themes,
|
||||
|
||||
+8
-1
@@ -7,6 +7,8 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||
import { useUserStore } from '@KTXC/stores/userStore'
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore'
|
||||
import { i18n } from '@KTXC/l10n/runtime'
|
||||
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
|
||||
import { FetchError } from '@KTXC/utils/helpers/fetch-wrapper-core'
|
||||
import { initializeModules } from '@KTXC/utils/modules'
|
||||
@@ -22,6 +24,7 @@ const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(PerfectScrollbarPlugin)
|
||||
app.use(i18n)
|
||||
app.use(vuetify)
|
||||
|
||||
const globalWindow = window as typeof window & {
|
||||
@@ -37,13 +40,17 @@ globalWindow.Pinia = PiniaLib as unknown
|
||||
const moduleStore = useModuleStore();
|
||||
const tenantStore = useTenantStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const l10nStore = useL10nStore();
|
||||
|
||||
try {
|
||||
const payload = await fetchWrapper.get('/init');
|
||||
moduleStore.init(payload?.modules ?? {});
|
||||
tenantStore.init(payload?.tenant ?? null);
|
||||
userStore.init(payload?.user ?? {});
|
||||
|
||||
// Resolve locale and load catalogs before modules boot
|
||||
await l10nStore.init(payload?.l10n ?? null);
|
||||
|
||||
// Initialize auth session monitor
|
||||
const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() });
|
||||
sessionMonitor.start();
|
||||
|
||||
+8
-1
@@ -3,6 +3,8 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import { AUTH_STORAGE_KEY } from '@KTXC/stores/userStore';
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore';
|
||||
import { i18n } from '@KTXC/l10n/runtime';
|
||||
import vuetify from './plugins/vuetify/index';
|
||||
|
||||
// Material Design Icons (Vuetify mdi icon set)
|
||||
@@ -19,6 +21,11 @@ const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
app.use(vuetify);
|
||||
|
||||
app.mount('#app');
|
||||
(async () => {
|
||||
// No session yet: locale comes from browser preferences alone
|
||||
await useL10nStore().init(null);
|
||||
app.mount('#app');
|
||||
})();
|
||||
|
||||
@@ -12,8 +12,11 @@ export { useTenantStore } from '../stores/tenantStore'
|
||||
export { useUserStore } from '../stores/userStore'
|
||||
export { useIntegrationStore } from '../stores/integrationStore'
|
||||
export { useLayoutStore } from '../stores/layoutStore'
|
||||
export { useL10nStore } from '../stores/l10nStore'
|
||||
|
||||
// Composables
|
||||
export { useL10n, t } from '../composables/useL10n'
|
||||
export type { L10N, TranslateParams } from '../composables/useL10n'
|
||||
export { useUser } from '../composables/useUser'
|
||||
export { useClipboard } from '../composables/useClipboard'
|
||||
export { useSnackbar } from '../composables/useSnackbar'
|
||||
|
||||
@@ -65,6 +65,11 @@ export const useIntegrationStore = defineStore('integrationStore', {
|
||||
// Remove 'type' field as it's only used for module-side disambiguation
|
||||
delete prefixed.type;
|
||||
|
||||
// Prefix localization keys with the module namespace
|
||||
if (entry.l10n) {
|
||||
prefixed.l10n = `${moduleHandle}.${entry.l10n}`;
|
||||
}
|
||||
|
||||
// Prefix internal paths
|
||||
if (entry.path) {
|
||||
prefixed.to = `/m/${moduleHandle}${entry.path}`;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { FALLBACK_LOCALE, mergeCatalog, applyLocale } from '@KTXC/l10n/runtime';
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
|
||||
const CORE_NAMESPACE = 'core';
|
||||
const SETTING_KEY = 'core.locale';
|
||||
const RTL_LANGUAGES = ['ar', 'fa', 'he', 'ur'];
|
||||
|
||||
export type CatalogMessages = Record<string, unknown>;
|
||||
export type CatalogProvider = (locale: string) => Promise<CatalogMessages | null>;
|
||||
|
||||
export interface L10nInitPayload {
|
||||
locale?: string;
|
||||
fallback?: string;
|
||||
available?: string[];
|
||||
}
|
||||
|
||||
export const useL10nStore = defineStore('l10nStore', () => {
|
||||
const locale = ref(FALLBACK_LOCALE);
|
||||
const available = ref<string[]>([FALLBACK_LOCALE]);
|
||||
|
||||
const isRtl = computed(() => RTL_LANGUAGES.includes(locale.value.split('-')[0]));
|
||||
|
||||
// Catalog providers know where their namespace's messages come from. The
|
||||
// store only coordinates them as the active locale changes.
|
||||
const catalogProviders = new Map<string, CatalogProvider>([
|
||||
[CORE_NAMESPACE, async (target: string) => {
|
||||
const response = await fetch(`/l10n/${target}.json`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
return response.ok ? await response.json() : null;
|
||||
}],
|
||||
]);
|
||||
|
||||
// catalog fetch results per "namespace:locale": true = merged, false = not on server
|
||||
const catalogState = new Map<string, boolean>();
|
||||
|
||||
async function loadCatalog(namespace: string, target: string): Promise<boolean> {
|
||||
const cacheKey = `${namespace}:${target}`;
|
||||
const known = catalogState.get(cacheKey);
|
||||
if (known !== undefined) return known;
|
||||
const provider = catalogProviders.get(namespace);
|
||||
if (!provider) return false;
|
||||
try {
|
||||
const messages = await provider(target);
|
||||
if (!messages) {
|
||||
catalogState.set(cacheKey, false);
|
||||
return false;
|
||||
}
|
||||
mergeCatalog(target, namespace, messages);
|
||||
catalogState.set(cacheKey, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// network error: leave state unset so a later attempt can retry
|
||||
console.warn(`L10N - Failed to load ${namespace} catalog for ${target}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoreCatalog(target: string): Promise<boolean> {
|
||||
return loadCatalog(CORE_NAMESPACE, target);
|
||||
}
|
||||
|
||||
/** Load every registered namespace (core + modules) for a locale. */
|
||||
async function loadCatalogs(target: string): Promise<void> {
|
||||
await Promise.all(
|
||||
[...catalogProviders.keys()].map((namespace) => loadCatalog(namespace, target)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a namespace-specific catalog provider. The caller owns catalog
|
||||
* discovery and transport; this store owns locale orchestration and merging.
|
||||
*/
|
||||
async function registerCatalogProvider(namespace: string, provider: CatalogProvider): Promise<void> {
|
||||
catalogProviders.set(namespace, provider);
|
||||
for (const cacheKey of catalogState.keys()) {
|
||||
if (cacheKey.startsWith(`${namespace}:`)) catalogState.delete(cacheKey);
|
||||
}
|
||||
await loadCatalog(namespace, FALLBACK_LOCALE);
|
||||
if (locale.value !== FALLBACK_LOCALE) {
|
||||
await loadCatalog(namespace, locale.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(target: string): Promise<void> {
|
||||
await loadCatalogs(FALLBACK_LOCALE);
|
||||
if (target !== FALLBACK_LOCALE) {
|
||||
await loadCatalogs(target);
|
||||
}
|
||||
locale.value = target;
|
||||
applyLocale(target);
|
||||
document.documentElement.setAttribute('lang', target);
|
||||
document.documentElement.setAttribute('dir', isRtl.value ? 'rtl' : 'ltr');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale candidates from browser preferences: each tag plus its base
|
||||
* language, in preference order ('de-AT' -> 'de-AT', 'de').
|
||||
*/
|
||||
function browserCandidates(): string[] {
|
||||
const candidates: string[] = [];
|
||||
for (const tag of navigator.languages ?? [navigator.language]) {
|
||||
if (!tag) continue;
|
||||
const base = tag.split('-')[0];
|
||||
if (!candidates.includes(tag)) candidates.push(tag);
|
||||
if (!candidates.includes(base)) candidates.push(base);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap from the /init l10n block (private shell), or from browser
|
||||
* preferences alone when there is no session (public shell).
|
||||
*/
|
||||
async function init(payload?: L10nInitPayload | null): Promise<void> {
|
||||
if (payload?.available?.length) {
|
||||
available.value = payload.available;
|
||||
}
|
||||
if (payload?.locale) {
|
||||
await activate(payload.locale);
|
||||
return;
|
||||
}
|
||||
// No resolved locale: probe browser candidates against served catalogs
|
||||
for (const candidate of browserCandidates()) {
|
||||
if (candidate === FALLBACK_LOCALE || (await loadCoreCatalog(candidate))) {
|
||||
await activate(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await activate(FALLBACK_LOCALE);
|
||||
}
|
||||
|
||||
/** Switch locale; persists as the user's `core.locale` setting by default. */
|
||||
async function setLocale(target: string, persist = true): Promise<void> {
|
||||
await activate(target);
|
||||
if (persist) {
|
||||
useUserStore().setSetting(SETTING_KEY, target);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
available,
|
||||
isRtl,
|
||||
init,
|
||||
setLocale,
|
||||
registerCatalogProvider,
|
||||
};
|
||||
});
|
||||
@@ -13,6 +13,8 @@ export interface IntegrationItem {
|
||||
moduleHandle: string;
|
||||
priority?: number;
|
||||
label?: string;
|
||||
/** Localization key for the label (module-relative; prefixed with the handle on registration). `label` is its fallback. */
|
||||
l10n?: string;
|
||||
caption?: string;
|
||||
icon?: string;
|
||||
to?: string;
|
||||
@@ -30,6 +32,8 @@ export interface IntegrationGroup {
|
||||
moduleHandle: string;
|
||||
priority?: number;
|
||||
label?: string;
|
||||
/** Localization key for the label (module-relative; prefixed with the handle on registration). `label` is its fallback. */
|
||||
l10n?: string;
|
||||
caption?: string;
|
||||
icon?: string;
|
||||
style?: IntegrationGroupStyle;
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { App } from 'vue';
|
||||
import { router } from '@KTXC/router';
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore';
|
||||
import type { CatalogMessages } from '@KTXC/stores/l10nStore';
|
||||
|
||||
function installModuleCSS(moduleHandle: string, cssPaths: string | string[]): void {
|
||||
const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths];
|
||||
@@ -46,12 +48,20 @@ function installModuleRoutes(moduleHandle: string, routes: any[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function installModuleIntegrations(
|
||||
moduleHandle: string,
|
||||
integrations: Record<string, any[]>
|
||||
): void {
|
||||
const integrationStore = useIntegrationStore();
|
||||
integrationStore.registerModuleIntegrations(moduleHandle, integrations);
|
||||
function installModuleIntegrations(moduleHandle: string, integrations: Record<string, any[]>): void {
|
||||
useIntegrationStore().registerModuleIntegrations(moduleHandle, integrations);
|
||||
}
|
||||
|
||||
async function installModuleLocalizations(moduleHandle: string): Promise<void> {
|
||||
await useL10nStore().registerCatalogProvider(moduleHandle, (locale) => loadModuleLocalizations(moduleHandle, locale));
|
||||
}
|
||||
|
||||
async function loadModuleLocalizations(moduleHandle: string, locale: string): Promise<CatalogMessages | null> {
|
||||
const response = await fetch(
|
||||
`/modules/${moduleHandle}/static/l10n/${locale}.json`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
);
|
||||
return response.ok ? await response.json() : null;
|
||||
}
|
||||
|
||||
export async function initializeModules(app: App): Promise<void> {
|
||||
@@ -72,11 +82,16 @@ export async function initializeModules(app: App): Promise<void> {
|
||||
console.log(`Module Loader - Loading ${moduleInfo.handle} from ${moduleUrl}`);
|
||||
|
||||
const loadPromise = import(/* @vite-ignore */ moduleUrl)
|
||||
.then((module) => {
|
||||
.then(async (module) => {
|
||||
// Load CSS if module explicitly exports css path(s)
|
||||
if (module.css) {
|
||||
installModuleCSS(moduleInfo.handle, module.css);
|
||||
}
|
||||
// Load translation catalogs if module explicitly exports l10n flag
|
||||
if (module.l10n) {
|
||||
console.log(`Module Loader - Installing Catalogs ${moduleInfo.handle}`);
|
||||
await installModuleLocalizations(moduleHandle);
|
||||
}
|
||||
// install module
|
||||
console.log(`Module Loader - Installing ${moduleInfo.handle}`);
|
||||
if (module.default && typeof module.default.install === 'function') {
|
||||
@@ -104,4 +119,4 @@ export async function initializeModules(app: App): Promise<void> {
|
||||
|
||||
// Wait for all dynamic loading to complete
|
||||
await Promise.all(loadPromises);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { RouterView } from 'vue-router';
|
||||
import LayoutHeader from '@KTXC/layouts/header/LayoutHeader.vue';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import LayoutSystemMenu from '@KTXC/layouts/menus/LayoutSystemMenu.vue';
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const l10nStore = useL10nStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-locale-provider>
|
||||
<v-locale-provider :rtl="l10nStore.isRtl">
|
||||
<v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']">
|
||||
<LayoutHeader />
|
||||
<LayoutSystemMenu />
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { RouterView } from 'vue-router';
|
||||
import LayoutFooter from '@KTXC/layouts/footer/LayoutFooter.vue';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const l10nStore = useL10nStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-locale-provider>
|
||||
<v-locale-provider :rtl="l10nStore.isRtl">
|
||||
<v-app :class="[layoutStore.miniSidebar ? 'mini-sidebar' : '']">
|
||||
<v-main class="page-wrapper">
|
||||
<v-container fluid>
|
||||
|
||||
Reference in New Issue
Block a user