i18n-dashboard 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-dashboard",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A web dashboard to manage vue-i18n translation keys with database persistence",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "bcryptjs": "^2.4.3",
31
31
  "commander": "^13.1.0",
32
32
  "knex": "^3.1.0",
33
- "nodemailer": "^6.10.1",
33
+ "nodemailer": "^8.0.3",
34
34
  "nuxt": "^3.21.1",
35
35
  "vue": "^3.5.13"
36
36
  },
@@ -75,20 +75,23 @@
75
75
  "localization"
76
76
  ],
77
77
  "license": "MIT",
78
+ "overrides": {
79
+ "flatted": "^3.4.2"
80
+ },
78
81
  "devDependencies": {
79
82
  "@iconify-json/heroicons": "^1.2.3",
80
83
  "@types/node": "^25.3.5",
81
84
  "@typescript-eslint/eslint-plugin": "^8.57.1",
82
85
  "@typescript-eslint/parser": "^8.57.1",
83
- "@vitest/coverage-v8": "^4.1.0",
84
- "@vitest/ui": "^4.1.0",
86
+ "@vitest/coverage-v8": "^4.1.1",
87
+ "@vitest/ui": "^4.1.1",
85
88
  "@vue/eslint-config-typescript": "^14.7.0",
86
89
  "@vue/test-utils": "^2.4.6",
87
90
  "eslint": "^10.0.3",
88
91
  "eslint-plugin-vue": "^10.8.0",
89
92
  "happy-dom": "^20.8.4",
90
93
  "husky": "^9.1.7",
91
- "vitest": "^4.1.0",
94
+ "vitest": "^4.1.1",
92
95
  "vue-eslint-parser": "^10.4.0"
93
96
  }
94
97
  }
package/src/app.vue CHANGED
@@ -1,7 +1,30 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Theme application: update appConfig.ui.colors so the @nuxt/ui colors plugin
4
+ * regenerates --ui-color-primary-* / --ui-color-neutral-* CSS variables.
5
+ * The plugin (node_modules/@nuxt/ui/dist/runtime/plugins/colors.js) is reactive —
6
+ * any change to appConfig.ui.colors triggers an automatic style tag update.
7
+ *
8
+ * useFetch is SSR-compatible, so the correct color is applied before the page
9
+ * reaches the browser (no flash of the default colour).
10
+ */
11
+ const { theme } = useModuleConfig()
12
+ const appConfig = useAppConfig()
13
+
14
+ watch(
15
+ theme,
16
+ (t) => {
17
+ if (t?.primary) appConfig.ui.colors.primary = t.primary
18
+ if (t?.neutral) appConfig.ui.colors.neutral = t.neutral
19
+ },
20
+ { immediate: true },
21
+ )
22
+ </script>
23
+
1
24
  <template>
2
25
  <UApp>
3
26
  <NuxtLoadingIndicator
4
- color="rgb(var(--ui-primary))"
27
+ color="var(--color-primary-500)"
5
28
  :height="3"
6
29
  :throttle="100"
7
30
  />
@@ -511,6 +511,7 @@
511
511
  "security.session_duration": "Session duration",
512
512
  "security.refresh_token_duration": "Refresh token duration",
513
513
  "security.reset_link_expiry": "Reset link expiry",
514
+ "security.nav_label": "Security",
514
515
 
515
516
  "logs.title": "System logs",
516
517
  "logs.description": "Error and warning logs generated by background processes",
@@ -558,5 +559,44 @@
558
559
  "smtp.gmail_step2": "If the page shows \"The setting you are looking for is not available\" → you must first enable 2-Step Verification on your Google account",
559
560
  "smtp.gmail_step3": "Type a name (e.g. \"i18n-dashboard\") and click Create",
560
561
  "smtp.gmail_step4": "Copy the 16-character code shown (e.g. abcd efgh ijkl mnop)",
561
- "smtp.gmail_step5": "Paste it in the Password field below — use your Gmail address as Username"
562
+ "smtp.gmail_step5": "Paste it in the Password field below — use your Gmail address as Username",
563
+
564
+ "customization.title": "Customization",
565
+ "customization.description": "Configure branding, theme and custom widgets",
566
+ "customization.config_file_active": "Config file is active",
567
+ "customization.config_file_description": "Values in i18n-dashboard.config.json override these settings.",
568
+ "customization.branding": "Branding",
569
+ "customization.app_name": "App name",
570
+ "customization.app_name_placeholder": "i18n Dashboard",
571
+ "customization.subtitle": "Subtitle",
572
+ "customization.subtitle_placeholder": "vue-i18n manager",
573
+ "customization.logo_url": "Logo URL",
574
+ "customization.logo_url_placeholder": "https://example.com/logo.png",
575
+ "customization.logo_url_hint": "URL or base64 data URI. Replaces the default language icon.",
576
+ "customization.logo_alt": "logo",
577
+ "customization.default_app_name": "i18n Dashboard",
578
+ "customization.default_subtitle": "vue-i18n manager",
579
+ "customization.theme": "Theme",
580
+ "customization.primary_color": "Primary color",
581
+ "customization.neutral_color": "Neutral color",
582
+ "customization.default": "Default",
583
+ "customization.custom_widgets": "Custom widgets",
584
+ "customization.no_custom_widgets": "No custom widgets defined",
585
+ "customization.custom_widgets_hint": "Add iframe-based widgets that appear in the dashboard widget picker",
586
+ "customization.saved": "Customization saved",
587
+ "customization.add_widget": "Add custom widget",
588
+ "customization.edit_widget": "Edit custom widget",
589
+ "customization.widget_type": "Type ID",
590
+ "customization.widget_type_placeholder": "my-metrics",
591
+ "customization.widget_type_hint": "Unique identifier, no spaces",
592
+ "customization.widget_label": "Label",
593
+ "customization.widget_label_placeholder": "My Metrics",
594
+ "customization.widget_description": "Description",
595
+ "customization.widget_description_placeholder": "Short description shown in the widget picker",
596
+ "customization.widget_url": "iframe URL",
597
+ "customization.widget_icon": "Icon",
598
+ "customization.widget_icon_hint": "Heroicons name, e.g. i-heroicons-chart-bar",
599
+ "customization.widget_sizes": "Available sizes",
600
+ "customization.widget_default_size": "Default size",
601
+ "customization.nav_label": "Customization"
562
602
  }
@@ -1,6 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import type { TWidgetType } from '../../types/dashboard.type'
3
- import { WIDGET_REGISTRY, WIDGET_SIZE_CLASSES } from '../../consts/dashboard.const'
2
+ import { WIDGET_SIZE_CLASSES } from '../../consts/dashboard.const'
4
3
 
5
4
  const props = defineProps<{
6
5
  projectId: number
@@ -28,12 +27,13 @@ const showPicker = ref(false)
28
27
  const configIndex = ref(-1)
29
28
 
30
29
  const activeLayout = computed(() => editing.value ? localLayout.value : layout.value)
30
+ const { registry: widgetRegistry, getEntry } = useWidgetRegistry()
31
31
 
32
32
  function sizeClass(size: string) {
33
33
  return WIDGET_SIZE_CLASSES[size as keyof typeof WIDGET_SIZE_CLASSES] ?? 'col-span-1 row-span-1'
34
34
  }
35
35
 
36
- function widgetComponent(type: TWidgetType) {
36
+ function widgetComponent(type: string) {
37
37
  switch (type) {
38
38
  case 'stat-keys':
39
39
  case 'stat-coverage':
@@ -49,6 +49,9 @@ function widgetComponent(type: TWidgetType) {
49
49
  case 'review-queue':
50
50
  return resolveComponent('DashboardWidgetsReviewWidget')
51
51
  default:
52
+ if (getEntry(type)?.isCustom) {
53
+ return resolveComponent('DashboardWidgetsCustomIframeWidget')
54
+ }
52
55
  return 'div'
53
56
  }
54
57
  }
@@ -150,7 +153,7 @@ function onSaveConfig(patch: { dataSource?: any; title?: string | undefined }) {
150
153
  class="absolute -top-2 right-0 z-10 flex gap-0.5"
151
154
  >
152
155
  <button
153
- v-for="s in WIDGET_REGISTRY[widget.type].sizes"
156
+ v-for="s in widgetRegistry[widget.type]?.sizes ?? []"
154
157
  :key="s"
155
158
  class="px-1 py-0.5 text-xs rounded shadow-sm transition-colors"
156
159
  :class="widget.size === s
@@ -164,7 +167,7 @@ function onSaveConfig(patch: { dataSource?: any; title?: string | undefined }) {
164
167
 
165
168
  <!-- Config button -->
166
169
  <button
167
- v-if="editing && WIDGET_REGISTRY[widget.type].hasDataSource"
170
+ v-if="editing && widgetRegistry[widget.type]?.hasDataSource"
168
171
  class="absolute bottom-1 right-1 z-10 w-6 h-6 bg-gray-600/80 dark:bg-gray-400/80 rounded-full text-white flex items-center justify-center text-xs hover:bg-gray-700 transition-colors"
169
172
  @click.stop="configIndex = index"
170
173
  >
@@ -1,6 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import type { TWidgetType } from '../../types/dashboard.type'
3
- import { WIDGET_REGISTRY, WIDGET_SIZE_CLASSES } from '../../consts/dashboard.const'
2
+ import { WIDGET_SIZE_CLASSES } from '../../consts/dashboard.const'
4
3
 
5
4
  const { t } = useT()
6
5
  const { currentUser } = useAuth()
@@ -25,12 +24,13 @@ const showPicker = ref(false)
25
24
  const configIndex = ref(-1)
26
25
 
27
26
  const activeLayout = computed(() => editing.value ? localLayout.value : layout.value)
27
+ const { registry: widgetRegistry, getEntry } = useWidgetRegistry()
28
28
 
29
29
  function sizeClass(size: string) {
30
30
  return WIDGET_SIZE_CLASSES[size as keyof typeof WIDGET_SIZE_CLASSES] ?? 'col-span-1 row-span-1'
31
31
  }
32
32
 
33
- function widgetComponent(type: TWidgetType) {
33
+ function widgetComponent(type: string) {
34
34
  switch (type) {
35
35
  case 'stat-keys':
36
36
  case 'stat-coverage':
@@ -46,6 +46,9 @@ function widgetComponent(type: TWidgetType) {
46
46
  case 'review-queue':
47
47
  return resolveComponent('DashboardWidgetsReviewWidget')
48
48
  default:
49
+ if (getEntry(type)?.isCustom) {
50
+ return resolveComponent('DashboardWidgetsCustomIframeWidget')
51
+ }
49
52
  return 'div'
50
53
  }
51
54
  }
@@ -156,7 +159,7 @@ function onSaveConfig(patch: { dataSource?: any; title?: string | undefined }) {
156
159
  class="absolute -top-2 right-0 z-10 flex gap-0.5"
157
160
  >
158
161
  <button
159
- v-for="s in WIDGET_REGISTRY[widget.type].sizes"
162
+ v-for="s in widgetRegistry[widget.type]?.sizes ?? []"
160
163
  :key="s"
161
164
  class="px-1 py-0.5 text-xs rounded shadow-sm transition-colors"
162
165
  :class="widget.size === s
@@ -169,7 +172,7 @@ function onSaveConfig(patch: { dataSource?: any; title?: string | undefined }) {
169
172
  </div>
170
173
 
171
174
  <button
172
- v-if="editing && WIDGET_REGISTRY[widget.type].hasDataSource"
175
+ v-if="editing && widgetRegistry[widget.type]?.hasDataSource"
173
176
  class="absolute bottom-1 right-1 z-10 w-6 h-6 bg-gray-600/80 dark:bg-gray-400/80 rounded-full text-white flex items-center justify-center text-xs hover:bg-gray-700 transition-colors"
174
177
  @click.stop="configIndex = index"
175
178
  >
@@ -1,7 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import type { TWidgetSize } from '../../types/dashboard.type'
3
3
  import type { IWidgetConfig } from '../../interfaces/dashboard.interface'
4
- import { WIDGET_REGISTRY } from '../../consts/dashboard.const'
5
4
 
6
5
  const { t } = useT()
7
6
 
@@ -20,20 +19,22 @@ const open = computed({
20
19
  set: (v) => emit('update:modelValue', v),
21
20
  })
22
21
 
22
+ const { registry } = useWidgetRegistry()
23
+
23
24
  const availableWidgets = computed(() =>
24
- Object.entries(WIDGET_REGISTRY).filter(([type]) => !props.excludeTypes?.includes(type)),
25
+ Object.entries(registry.value).filter(([type]) => !props.excludeTypes?.includes(type)),
25
26
  )
26
27
 
27
28
  const selectedSizes = ref<Record<string, TWidgetSize>>({})
28
29
 
29
30
  function getSelectedSize(type: string): TWidgetSize {
30
- return selectedSizes.value[type] ?? WIDGET_REGISTRY[type as keyof typeof WIDGET_REGISTRY].defaultSize
31
+ return selectedSizes.value[type] ?? registry.value[type]?.defaultSize ?? 'md'
31
32
  }
32
33
 
33
34
  function addWidget(type: string) {
34
35
  const size = getSelectedSize(type)
35
36
  const id = Date.now().toString(36)
36
- emit('add', { id, type: type as IWidgetConfig['type'], size })
37
+ emit('add', { id, type, size })
37
38
  open.value = false
38
39
  }
39
40
  </script>
@@ -52,16 +53,27 @@ function addWidget(type: string) {
52
53
  class="border border-gray-200 dark:border-gray-700 rounded-xl p-4 space-y-3 hover:border-primary-300 dark:hover:border-primary-600 transition-colors"
53
54
  >
54
55
  <div class="flex items-start gap-3">
55
- <div class="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center shrink-0">
56
+ <div
57
+ class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
58
+ :class="config.isCustom ? 'bg-primary-50 dark:bg-primary-950' : 'bg-gray-100 dark:bg-gray-800'"
59
+ >
56
60
  <UIcon
57
61
  :name="config.icon"
58
- class="text-gray-600 dark:text-gray-400"
62
+ :class="config.isCustom ? 'text-primary-500' : 'text-gray-600 dark:text-gray-400'"
59
63
  />
60
64
  </div>
61
- <div class="min-w-0">
62
- <p class="text-sm font-semibold text-gray-900 dark:text-white">
63
- {{ config.label }}
64
- </p>
65
+ <div class="min-w-0 flex-1">
66
+ <div class="flex items-center gap-1.5">
67
+ <p class="text-sm font-semibold text-gray-900 dark:text-white">
68
+ {{ config.label }}
69
+ </p>
70
+ <span
71
+ v-if="config.isCustom"
72
+ class="text-[10px] px-1.5 py-0.5 rounded-full bg-primary-100 dark:bg-primary-900 text-primary-600 dark:text-primary-400 font-medium leading-none"
73
+ >
74
+ {{ t('dashboard.custom', 'Custom') }}
75
+ </span>
76
+ </div>
65
77
  <p class="text-xs text-gray-400 mt-0.5">
66
78
  {{ config.description }}
67
79
  </p>
@@ -0,0 +1,56 @@
1
+ <script lang="ts" setup>
2
+ import type { ICustomWidgetDef } from '../../../interfaces/project-config.interface'
3
+
4
+ const props = defineProps<{
5
+ id: string
6
+ type: string
7
+ size: string
8
+ editing?: boolean
9
+ title?: string
10
+ }>()
11
+
12
+ const { customWidgets } = useModuleConfig()
13
+ const def = computed<ICustomWidgetDef | undefined>(() =>
14
+ customWidgets.value.find(w => w.type === props.type),
15
+ )
16
+ const url = computed(() =>
17
+ def.value?.config?.kind === 'iframe' ? def.value.config.url : null,
18
+ )
19
+ const label = computed(() => props.title || def.value?.label || props.type)
20
+ </script>
21
+
22
+ <template>
23
+ <div class="w-full h-full relative rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
24
+ <!-- Edit-mode overlay -->
25
+ <div
26
+ v-if="editing"
27
+ class="absolute inset-0 z-10 bg-gray-900/30 flex flex-col items-center justify-center gap-2 pointer-events-none"
28
+ >
29
+ <UIcon
30
+ name="i-heroicons-puzzle-piece"
31
+ class="text-white text-3xl"
32
+ />
33
+ <span class="text-white text-xs font-medium">{{ label }}</span>
34
+ </div>
35
+
36
+ <iframe
37
+ v-if="url"
38
+ :src="url"
39
+ :title="label"
40
+ class="w-full h-full border-0"
41
+ loading="lazy"
42
+ sandbox="allow-scripts"
43
+ />
44
+ <div
45
+ v-else
46
+ class="w-full h-full flex flex-col items-center justify-center text-gray-400 gap-2"
47
+ >
48
+ <UIcon
49
+ name="i-heroicons-puzzle-piece"
50
+ class="text-3xl"
51
+ />
52
+ <span class="text-xs">{{ label }}</span>
53
+ <span class="text-xs text-gray-300 dark:text-gray-600">No URL configured</span>
54
+ </div>
55
+ </div>
56
+ </template>
@@ -0,0 +1,24 @@
1
+ import type { IBrandingConfig, IThemeConfig, ICustomWidgetDef } from '../interfaces/project-config.interface'
2
+
3
+ interface IAppConfigResponse {
4
+ branding: IBrandingConfig | null
5
+ theme: IThemeConfig | null
6
+ widgets: { custom?: ICustomWidgetDef[] } | null
7
+ }
8
+
9
+ /**
10
+ * Fetches branding / theme / custom-widget config from the server.
11
+ * useFetch deduplicates by key — all callers share the same cached response.
12
+ */
13
+ export function useModuleConfig() {
14
+ const { data, refresh } = useFetch<IAppConfigResponse>('/api/app-config', {
15
+ key: 'module-config',
16
+ default: () => ({ branding: null, theme: null, widgets: null }),
17
+ })
18
+
19
+ const branding = computed(() => data.value?.branding ?? null)
20
+ const theme = computed(() => data.value?.theme ?? null)
21
+ const customWidgets = computed<ICustomWidgetDef[]>(() => data.value?.widgets?.custom ?? [])
22
+
23
+ return { branding, theme, customWidgets, refresh }
24
+ }
@@ -0,0 +1,38 @@
1
+ import { WIDGET_REGISTRY } from '../consts/dashboard.const'
2
+ import type { TWidgetSize } from '../types/dashboard.type'
3
+
4
+ export interface IWidgetRegistryEntry {
5
+ label: string
6
+ description: string
7
+ icon: string
8
+ sizes: TWidgetSize[]
9
+ defaultSize: TWidgetSize
10
+ hasDataSource: boolean
11
+ isCustom?: boolean
12
+ }
13
+
14
+ export function useWidgetRegistry() {
15
+ const { customWidgets } = useModuleConfig()
16
+
17
+ const registry = computed<Record<string, IWidgetRegistryEntry>>(() => {
18
+ const base: Record<string, IWidgetRegistryEntry> = { ...WIDGET_REGISTRY }
19
+ for (const w of customWidgets.value) {
20
+ base[w.type] = {
21
+ label: w.label,
22
+ description: w.description ?? '',
23
+ icon: w.icon ?? 'i-heroicons-puzzle-piece',
24
+ sizes: (w.sizes as TWidgetSize[]) ?? ['md', 'lg'],
25
+ defaultSize: (w.defaultSize as TWidgetSize) ?? 'md',
26
+ hasDataSource: false,
27
+ isCustom: true,
28
+ }
29
+ }
30
+ return base
31
+ })
32
+
33
+ function getEntry(type: string): IWidgetRegistryEntry | undefined {
34
+ return registry.value[type]
35
+ }
36
+
37
+ return { registry, getEntry }
38
+ }
@@ -5,7 +5,7 @@ export interface IWidgetDataSource {
5
5
 
6
6
  export interface IWidgetConfig {
7
7
  id: string
8
- type: 'stat-keys' | 'stat-coverage' | 'stat-languages' | 'stat-unused' | 'projects' | 'languages-coverage' | 'last-activity' | 'review-queue'
8
+ type: string // built-in TWidgetType or any custom widget type defined in config
9
9
  size: 'sm' | 'md' | 'lg' | 'wide' | 'xl'
10
10
  dataSource?: IWidgetDataSource // default = 'global'
11
11
  title?: string // optional custom title override
@@ -1,9 +1,40 @@
1
+ export interface IBrandingConfig {
2
+ name?: string // App name shown in sidebar (default: "i18n Dashboard")
3
+ subtitle?: string // Subtitle below name (default: "vue-i18n manager")
4
+ logoUrl?: string // URL or base64 data URI for the logo image
5
+ }
6
+
7
+ export interface IThemeConfig {
8
+ primary?: string // Tailwind color name: 'blue' | 'violet' | 'green' | 'red' | 'orange' | 'teal' | 'cyan' | 'indigo' | 'purple' | 'pink' | 'rose' | 'sky' | 'emerald' | 'amber' | 'lime' | 'fuchsia' | 'yellow'
9
+ neutral?: string // Tailwind neutral: 'slate' | 'gray' | 'zinc' | 'neutral' | 'stone'
10
+ }
11
+
12
+ export interface ICustomWidgetDef {
13
+ type: string // Unique identifier (e.g. "my-metric")
14
+ label: string // Display label in widget picker
15
+ description?: string // Short description
16
+ icon?: string // Heroicons name (e.g. "i-heroicons-chart-bar")
17
+ sizes: string[] // Allowed sizes: sm | md | lg | wide | xl
18
+ defaultSize: string // Default size when added
19
+ config: IIframeWidgetConfig
20
+ }
21
+
22
+ export interface IIframeWidgetConfig {
23
+ kind: 'iframe'
24
+ url: string // URL to embed in an iframe
25
+ }
26
+
1
27
  export interface IDashboardConfig {
2
- uiLanguages?: string[] // e.g. ["fr", "en", "es"]
3
- defaultUiLanguage?: string // e.g. "fr"
4
- project?: {
5
- name?: string
6
- localesPath?: string
7
- keySeparator?: string
8
- }
28
+ uiLanguages?: string[] // e.g. ["fr", "en", "es"]
29
+ defaultUiLanguage?: string // e.g. "fr"
30
+ project?: {
31
+ name?: string
32
+ localesPath?: string
33
+ keySeparator?: string
34
+ }
35
+ branding?: IBrandingConfig
36
+ theme?: IThemeConfig
37
+ widgets?: {
38
+ custom?: ICustomWidgetDef[]
39
+ }
9
40
  }
@@ -24,18 +24,25 @@
24
24
  to="/"
25
25
  class="flex items-center gap-2.5"
26
26
  >
27
- <div class="w-8 h-8 rounded-lg bg-primary-500 flex items-center justify-center shrink-0">
27
+ <div class="w-8 h-8 rounded-lg bg-primary-500 flex items-center justify-center shrink-0 overflow-hidden">
28
+ <img
29
+ v-if="logoUrl"
30
+ :src="logoUrl"
31
+ :alt="appName"
32
+ class="w-full h-full object-cover"
33
+ >
28
34
  <UIcon
35
+ v-else
29
36
  name="i-heroicons-language"
30
37
  class="text-white text-base"
31
38
  />
32
39
  </div>
33
40
  <div>
34
41
  <h1 class="text-sm font-bold text-gray-900 dark:text-white leading-tight">
35
- i18n Dashboard
42
+ {{ appName }}
36
43
  </h1>
37
44
  <p class="text-xs text-gray-400">
38
- vue-i18n manager
45
+ {{ appSubtitle }}
39
46
  </p>
40
47
  </div>
41
48
  </NuxtLink>
@@ -225,7 +232,20 @@
225
232
  name="i-heroicons-shield-check"
226
233
  class="text-base shrink-0"
227
234
  />
228
- <span class="flex-1">Sécurité</span>
235
+ <span class="flex-1">{{ t('security.nav_label', 'Security') }}</span>
236
+ </NuxtLink>
237
+ <NuxtLink
238
+ to="/admin/customization"
239
+ class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
240
+ :class="isActive('/admin/customization')
241
+ ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 font-medium'
242
+ : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'"
243
+ >
244
+ <UIcon
245
+ name="i-heroicons-paint-brush"
246
+ class="text-base shrink-0"
247
+ />
248
+ <span class="flex-1">{{ t('customization.nav_label', 'Customization') }}</span>
229
249
  </NuxtLink>
230
250
  <NuxtLink
231
251
  to="/admin/smtp"
@@ -390,6 +410,10 @@ const route = useRoute()
390
410
  const router = useRouter()
391
411
  const toast = useToast()
392
412
  const colorMode = useColorMode()
413
+ const { branding } = useModuleConfig()
414
+ const appName = computed(() => branding.value?.name || 'i18n Dashboard')
415
+ const appSubtitle = computed(() => branding.value?.subtitle || 'vue-i18n manager')
416
+ const logoUrl = computed(() => branding.value?.logoUrl || null)
393
417
  const { currentProject, projects: projectsData, systemProject, fetchProjects, visibleProjects: userProjects, syncing, syncProject, pending } = useProject()
394
418
 
395
419
  const appReady = ref(false)