i18n-dashboard 0.16.1 → 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.1",
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": {
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)
@@ -0,0 +1,567 @@
1
+ <script lang="ts" setup>
2
+ import type { ICustomWidgetDef } from '../../interfaces/project-config.interface'
3
+
4
+ const { t } = useT()
5
+ const toast = useToast()
6
+ const { refresh: refreshModuleConfig } = useModuleConfig()
7
+
8
+ const PRIMARY_COLORS = [
9
+ 'red', 'orange', 'amber', 'yellow', 'lime',
10
+ 'green', 'emerald', 'teal', 'cyan', 'sky',
11
+ 'blue', 'indigo', 'violet', 'purple', 'fuchsia',
12
+ 'pink', 'rose',
13
+ ]
14
+
15
+ const NEUTRAL_COLORS = ['slate', 'gray', 'zinc', 'neutral', 'stone']
16
+
17
+ // Hardcoded palette values (shade 500 for primary, shade 400 for neutral).
18
+ // Tailwind v4 only generates CSS variables for statically-referenced colors,
19
+ // so dynamic `var(--color-${color}-500)` references are undefined at runtime.
20
+ const COLOR_HEX: Record<string, string> = {
21
+ // primary (500)
22
+ red: '#ef4444',
23
+ orange: '#f97316',
24
+ amber: '#f59e0b',
25
+ yellow: '#eab308',
26
+ lime: '#84cc16',
27
+ green: '#22c55e',
28
+ emerald: '#10b981',
29
+ teal: '#14b8a6',
30
+ cyan: '#06b6d4',
31
+ sky: '#0ea5e9',
32
+ blue: '#3b82f6',
33
+ indigo: '#6366f1',
34
+ violet: '#8b5cf6',
35
+ purple: '#a855f7',
36
+ fuchsia: '#d946ef',
37
+ pink: '#ec4899',
38
+ rose: '#f43f5e',
39
+ // neutral (400)
40
+ slate: '#94a3b8',
41
+ gray: '#9ca3af',
42
+ zinc: '#a1a1aa',
43
+ neutral: '#a3a3a3',
44
+ stone: '#a8a29e',
45
+ }
46
+
47
+ const appConfig = useAppConfig()
48
+
49
+ // ── State ──────────────────────────────────────────────────────────────────
50
+
51
+ const pending = ref(true)
52
+ const saving = ref(false)
53
+ const hasConfigFile = ref(false)
54
+
55
+ const branding = reactive({ name: '', subtitle: '', logoUrl: '' })
56
+ const theme = reactive({ primary: '', neutral: '' })
57
+
58
+ // Live preview: update appConfig so the @nuxt/ui colors plugin applies
59
+ // changes immediately as the user clicks swatches (before saving to DB)
60
+ watch(() => theme.primary, (color) => { if (color) appConfig.ui.colors.primary = color })
61
+ watch(() => theme.neutral, (color) => { if (color) appConfig.ui.colors.neutral = color })
62
+ const customWidgets = ref<ICustomWidgetDef[]>([])
63
+
64
+ // Widget editor
65
+ const showWidgetEditor = ref(false)
66
+ const editingWidget = ref<ICustomWidgetDef | null>(null)
67
+ const editingIndex = ref(-1)
68
+ const widgetForm = reactive<ICustomWidgetDef>({
69
+ type: '',
70
+ label: '',
71
+ description: '',
72
+ icon: 'i-heroicons-puzzle-piece',
73
+ sizes: ['md', 'lg', 'wide'],
74
+ defaultSize: 'md',
75
+ config: { kind: 'iframe', url: '' },
76
+ })
77
+ const WIDGET_SIZES = ['sm', 'md', 'lg', 'wide', 'xl']
78
+
79
+ // ── Load ───────────────────────────────────────────────────────────────────
80
+
81
+ const { data, refresh } = await useFetch('/api/admin/customization', { key: 'admin-customization' })
82
+
83
+ watchEffect(() => {
84
+ if (!data.value) return
85
+ hasConfigFile.value = data.value.hasConfigFile ?? false
86
+ Object.assign(branding, data.value.branding ?? {})
87
+ Object.assign(theme, data.value.theme ?? {})
88
+ customWidgets.value = data.value.customWidgets ?? []
89
+ pending.value = false
90
+ })
91
+
92
+ // ── Save ───────────────────────────────────────────────────────────────────
93
+
94
+ async function save() {
95
+ saving.value = true
96
+ try {
97
+ await $fetch('/api/admin/customization', {
98
+ method: 'POST',
99
+ body: {
100
+ branding: { ...branding },
101
+ theme: { ...theme },
102
+ customWidgets: customWidgets.value,
103
+ },
104
+ })
105
+ await refreshModuleConfig()
106
+ toast.add({ title: t('customization.saved', 'Customization saved'), color: 'success' })
107
+ }
108
+ catch {
109
+ toast.add({ title: t('common.error', 'An error occurred'), color: 'error' })
110
+ }
111
+ finally {
112
+ saving.value = false
113
+ }
114
+ }
115
+
116
+ // ── Widget editor ──────────────────────────────────────────────────────────
117
+
118
+ function openAddWidget() {
119
+ Object.assign(widgetForm, {
120
+ type: '',
121
+ label: '',
122
+ description: '',
123
+ icon: 'i-heroicons-puzzle-piece',
124
+ sizes: ['md', 'lg', 'wide'],
125
+ defaultSize: 'md',
126
+ config: { kind: 'iframe', url: '' },
127
+ })
128
+ editingIndex.value = -1
129
+ showWidgetEditor.value = true
130
+ }
131
+
132
+ function openEditWidget(widget: ICustomWidgetDef, index: number) {
133
+ Object.assign(widgetForm, {
134
+ ...widget,
135
+ sizes: [...widget.sizes],
136
+ config: { ...widget.config },
137
+ })
138
+ editingIndex.value = index
139
+ showWidgetEditor.value = true
140
+ }
141
+
142
+ function toggleWidgetSize(size: string) {
143
+ const idx = widgetForm.sizes.indexOf(size)
144
+ if (idx === -1) {
145
+ widgetForm.sizes.push(size)
146
+ }
147
+ else if (widgetForm.sizes.length > 1) {
148
+ widgetForm.sizes.splice(idx, 1)
149
+ if (widgetForm.defaultSize === size) {
150
+ widgetForm.defaultSize = widgetForm.sizes[0]
151
+ }
152
+ }
153
+ }
154
+
155
+ function saveWidget() {
156
+ if (!widgetForm.type || !widgetForm.label || !widgetForm.config.url) return
157
+ const widget: ICustomWidgetDef = {
158
+ type: widgetForm.type,
159
+ label: widgetForm.label,
160
+ description: widgetForm.description,
161
+ icon: widgetForm.icon,
162
+ sizes: [...widgetForm.sizes],
163
+ defaultSize: widgetForm.defaultSize,
164
+ config: { kind: 'iframe', url: widgetForm.config.url },
165
+ }
166
+ if (editingIndex.value === -1) {
167
+ customWidgets.value.push(widget)
168
+ }
169
+ else {
170
+ customWidgets.value[editingIndex.value] = widget
171
+ }
172
+ showWidgetEditor.value = false
173
+ }
174
+
175
+ function removeWidget(index: number) {
176
+ customWidgets.value.splice(index, 1)
177
+ }
178
+ </script>
179
+
180
+ <template>
181
+ <div class="p-6">
182
+ <div class="mb-6">
183
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
184
+ {{ t('customization.title', 'Customization') }}
185
+ </h1>
186
+ <p class="text-gray-500 dark:text-gray-400 mt-1 text-sm">
187
+ {{ t('customization.description', 'Configure branding, theme and custom widgets') }}
188
+ </p>
189
+ </div>
190
+
191
+ <!-- Config file override notice -->
192
+ <UAlert
193
+ v-if="hasConfigFile"
194
+ class="mb-6"
195
+ icon="i-heroicons-information-circle"
196
+ color="info"
197
+ :title="t('customization.config_file_active', 'Config file is active')"
198
+ :description="t('customization.config_file_description', 'Values in i18n-dashboard.config.json override these settings.')"
199
+ />
200
+
201
+ <div class="max-w-3xl space-y-6">
202
+ <!-- ── Branding ──────────────────────────────────────────────────── -->
203
+ <UCard>
204
+ <template #header>
205
+ <div class="flex items-center gap-2">
206
+ <UIcon
207
+ name="i-heroicons-paint-brush"
208
+ class="text-primary-500"
209
+ />
210
+ <h2 class="font-semibold text-gray-900 dark:text-white">
211
+ {{ t('customization.branding', 'Branding') }}
212
+ </h2>
213
+ </div>
214
+ </template>
215
+
216
+ <div class="space-y-4">
217
+ <UFormField :label="t('customization.app_name', 'App name')">
218
+ <UInput
219
+ v-model="branding.name"
220
+ :placeholder="t('customization.app_name_placeholder', 'i18n Dashboard')"
221
+ class="w-full"
222
+ />
223
+ </UFormField>
224
+ <UFormField :label="t('customization.subtitle', 'Subtitle')">
225
+ <UInput
226
+ v-model="branding.subtitle"
227
+ :placeholder="t('customization.subtitle_placeholder', 'vue-i18n manager')"
228
+ class="w-full"
229
+ />
230
+ </UFormField>
231
+ <UFormField :label="t('customization.logo_url', 'Logo URL')">
232
+ <UInput
233
+ v-model="branding.logoUrl"
234
+ :placeholder="t('customization.logo_url_placeholder', 'https://example.com/logo.png')"
235
+ class="w-full"
236
+ />
237
+ <template #help>
238
+ {{ t('customization.logo_url_hint', 'URL or base64 data URI. Replaces the default language icon.') }}
239
+ </template>
240
+ </UFormField>
241
+
242
+ <!-- Logo preview -->
243
+ <div
244
+ v-if="branding.logoUrl"
245
+ class="flex items-center gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800"
246
+ >
247
+ <div class="w-8 h-8 rounded-lg bg-primary-500 flex items-center justify-center overflow-hidden shrink-0">
248
+ <img
249
+ :src="branding.logoUrl"
250
+ :alt="branding.name || t('customization.logo_alt', 'logo')"
251
+ class="w-full h-full object-cover"
252
+ >
253
+ </div>
254
+ <div>
255
+ <p class="text-sm font-bold text-gray-900 dark:text-white">
256
+ {{ branding.name || t('customization.default_app_name', 'i18n Dashboard') }}
257
+ </p>
258
+ <p class="text-xs text-gray-400">
259
+ {{ branding.subtitle || t('customization.default_subtitle', 'vue-i18n manager') }}
260
+ </p>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ </UCard>
265
+
266
+ <!-- ── Theme ─────────────────────────────────────────────────────── -->
267
+ <UCard>
268
+ <template #header>
269
+ <div class="flex items-center gap-2">
270
+ <UIcon
271
+ name="i-heroicons-swatch"
272
+ class="text-primary-500"
273
+ />
274
+ <h2 class="font-semibold text-gray-900 dark:text-white">
275
+ {{ t('customization.theme', 'Theme') }}
276
+ </h2>
277
+ </div>
278
+ </template>
279
+
280
+ <div class="space-y-5">
281
+ <!-- Primary color -->
282
+ <UFormField :label="t('customization.primary_color', 'Primary color')">
283
+ <div class="flex flex-wrap gap-2 mt-1">
284
+ <button
285
+ v-for="color in PRIMARY_COLORS"
286
+ :key="color"
287
+ class="w-7 h-7 rounded-full border-2 transition-all"
288
+ :class="theme.primary === color
289
+ ? 'border-gray-900 dark:border-white scale-110'
290
+ : 'border-black/10 hover:scale-105'"
291
+ :style="{ backgroundColor: COLOR_HEX[color] }"
292
+ :title="color"
293
+ @click="theme.primary = theme.primary === color ? '' : color"
294
+ />
295
+ <button
296
+ class="w-7 h-7 rounded-full border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center text-gray-400 hover:border-gray-400 transition-colors"
297
+ :class="!theme.primary ? 'border-gray-900 dark:border-white' : ''"
298
+ :title="t('customization.default', 'Default')"
299
+ @click="theme.primary = ''"
300
+ >
301
+ <UIcon
302
+ name="i-heroicons-x-mark"
303
+ class="text-xs"
304
+ />
305
+ </button>
306
+ </div>
307
+ <p
308
+ v-if="theme.primary"
309
+ class="mt-1.5 text-xs text-gray-400"
310
+ >
311
+ {{ theme.primary }}
312
+ </p>
313
+ </UFormField>
314
+
315
+ <!-- Neutral color -->
316
+ <UFormField :label="t('customization.neutral_color', 'Neutral color')">
317
+ <div class="flex flex-wrap gap-2 mt-1">
318
+ <button
319
+ v-for="color in NEUTRAL_COLORS"
320
+ :key="color"
321
+ class="w-7 h-7 rounded-full border-2 transition-all"
322
+ :class="theme.neutral === color
323
+ ? 'border-gray-900 dark:border-white scale-110'
324
+ : 'border-black/10 hover:scale-105'"
325
+ :style="{ backgroundColor: COLOR_HEX[color] }"
326
+ :title="color"
327
+ @click="theme.neutral = theme.neutral === color ? '' : color"
328
+ />
329
+ <button
330
+ class="w-7 h-7 rounded-full border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center text-gray-400 hover:border-gray-400 transition-colors"
331
+ :class="!theme.neutral ? 'border-gray-900 dark:border-white' : ''"
332
+ :title="t('customization.default', 'Default')"
333
+ @click="theme.neutral = ''"
334
+ >
335
+ <UIcon
336
+ name="i-heroicons-x-mark"
337
+ class="text-xs"
338
+ />
339
+ </button>
340
+ </div>
341
+ <p
342
+ v-if="theme.neutral"
343
+ class="mt-1.5 text-xs text-gray-400"
344
+ >
345
+ {{ theme.neutral }}
346
+ </p>
347
+ </UFormField>
348
+ </div>
349
+ </UCard>
350
+
351
+ <!-- ── Custom widgets ────────────────────────────────────────────── -->
352
+ <UCard>
353
+ <template #header>
354
+ <div class="flex items-center justify-between">
355
+ <div class="flex items-center gap-2">
356
+ <UIcon
357
+ name="i-heroicons-puzzle-piece"
358
+ class="text-primary-500"
359
+ />
360
+ <h2 class="font-semibold text-gray-900 dark:text-white">
361
+ {{ t('customization.custom_widgets', 'Custom widgets') }}
362
+ </h2>
363
+ </div>
364
+ <UButton
365
+ size="xs"
366
+ icon="i-heroicons-plus"
367
+ variant="outline"
368
+ color="neutral"
369
+ @click="openAddWidget"
370
+ >
371
+ {{ t('common.add', 'Add') }}
372
+ </UButton>
373
+ </div>
374
+ </template>
375
+
376
+ <div
377
+ v-if="customWidgets.length === 0"
378
+ class="text-center py-8 text-gray-400"
379
+ >
380
+ <UIcon
381
+ name="i-heroicons-puzzle-piece"
382
+ class="text-3xl mb-2"
383
+ />
384
+ <p class="text-sm">
385
+ {{ t('customization.no_custom_widgets', 'No custom widgets defined') }}
386
+ </p>
387
+ <p class="text-xs mt-1">
388
+ {{ t('customization.custom_widgets_hint', 'Add iframe-based widgets that appear in the dashboard widget picker') }}
389
+ </p>
390
+ </div>
391
+
392
+ <div
393
+ v-else
394
+ class="space-y-2"
395
+ >
396
+ <div
397
+ v-for="(widget, index) in customWidgets"
398
+ :key="widget.type"
399
+ class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700"
400
+ >
401
+ <div class="w-8 h-8 rounded-lg bg-primary-50 dark:bg-primary-950 flex items-center justify-center shrink-0">
402
+ <UIcon
403
+ :name="widget.icon || 'i-heroicons-puzzle-piece'"
404
+ class="text-primary-500"
405
+ />
406
+ </div>
407
+ <div class="flex-1 min-w-0">
408
+ <p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
409
+ {{ widget.label }}
410
+ </p>
411
+ <p class="text-xs text-gray-400 truncate">
412
+ {{ widget.config.url }}
413
+ </p>
414
+ </div>
415
+ <div class="flex items-center gap-1 shrink-0">
416
+ <UButton
417
+ size="xs"
418
+ variant="ghost"
419
+ color="neutral"
420
+ icon="i-heroicons-pencil"
421
+ @click="openEditWidget(widget, index)"
422
+ />
423
+ <UButton
424
+ size="xs"
425
+ variant="ghost"
426
+ color="error"
427
+ icon="i-heroicons-trash"
428
+ @click="removeWidget(index)"
429
+ />
430
+ </div>
431
+ </div>
432
+ </div>
433
+ </UCard>
434
+
435
+ <!-- Save button -->
436
+ <div class="flex justify-end">
437
+ <UButton
438
+ :loading="saving"
439
+ icon="i-heroicons-check"
440
+ @click="save"
441
+ >
442
+ {{ t('common.save', 'Save') }}
443
+ </UButton>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- ── Widget editor modal ─────────────────────────────────────────── -->
448
+ <UModal
449
+ v-model:open="showWidgetEditor"
450
+ :title="editingIndex === -1
451
+ ? t('customization.add_widget', 'Add custom widget')
452
+ : t('customization.edit_widget', 'Edit custom widget')"
453
+ >
454
+ <template #body>
455
+ <div class="space-y-4 p-1">
456
+ <div class="grid grid-cols-2 gap-4">
457
+ <UFormField
458
+ :label="t('customization.widget_type', 'Type ID')"
459
+ required
460
+ >
461
+ <UInput
462
+ v-model="widgetForm.type"
463
+ :placeholder="t('customization.widget_type_placeholder', 'my-metrics')"
464
+ :disabled="editingIndex !== -1"
465
+ class="w-full"
466
+ />
467
+ <template #help>
468
+ {{ t('customization.widget_type_hint', 'Unique identifier, no spaces') }}
469
+ </template>
470
+ </UFormField>
471
+
472
+ <UFormField
473
+ :label="t('customization.widget_label', 'Label')"
474
+ required
475
+ >
476
+ <UInput
477
+ v-model="widgetForm.label"
478
+ :placeholder="t('customization.widget_label_placeholder', 'My Metrics')"
479
+ class="w-full"
480
+ />
481
+ </UFormField>
482
+ </div>
483
+
484
+ <UFormField :label="t('customization.widget_description', 'Description')">
485
+ <UInput
486
+ v-model="widgetForm.description"
487
+ :placeholder="t('customization.widget_description_placeholder', 'Short description shown in the widget picker')"
488
+ class="w-full"
489
+ />
490
+ </UFormField>
491
+
492
+ <UFormField
493
+ :label="t('customization.widget_url', 'iframe URL')"
494
+ required
495
+ >
496
+ <UInput
497
+ v-model="widgetForm.config.url"
498
+ placeholder="https://example.com/embed"
499
+ class="w-full"
500
+ />
501
+ </UFormField>
502
+
503
+ <UFormField :label="t('customization.widget_icon', 'Icon')">
504
+ <UInput
505
+ v-model="widgetForm.icon"
506
+ placeholder="i-heroicons-chart-bar"
507
+ class="w-full"
508
+ />
509
+ <template #help>
510
+ {{ t('customization.widget_icon_hint', 'Heroicons name, e.g. i-heroicons-chart-bar') }}
511
+ </template>
512
+ </UFormField>
513
+
514
+ <UFormField :label="t('customization.widget_sizes', 'Available sizes')">
515
+ <div class="flex gap-2 mt-1">
516
+ <button
517
+ v-for="size in WIDGET_SIZES"
518
+ :key="size"
519
+ class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
520
+ :class="widgetForm.sizes.includes(size)
521
+ ? 'bg-primary-500 border-primary-500 text-white'
522
+ : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-primary-400'"
523
+ @click="toggleWidgetSize(size)"
524
+ >
525
+ {{ size }}
526
+ </button>
527
+ </div>
528
+ </UFormField>
529
+
530
+ <UFormField :label="t('customization.widget_default_size', 'Default size')">
531
+ <div class="flex gap-2 mt-1">
532
+ <button
533
+ v-for="size in widgetForm.sizes"
534
+ :key="size"
535
+ class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
536
+ :class="widgetForm.defaultSize === size
537
+ ? 'bg-primary-500 border-primary-500 text-white'
538
+ : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-primary-400'"
539
+ @click="widgetForm.defaultSize = size"
540
+ >
541
+ {{ size }}
542
+ </button>
543
+ </div>
544
+ </UFormField>
545
+ </div>
546
+ </template>
547
+
548
+ <template #footer>
549
+ <div class="flex justify-end gap-2">
550
+ <UButton
551
+ variant="ghost"
552
+ color="neutral"
553
+ @click="showWidgetEditor = false"
554
+ >
555
+ {{ t('common.cancel', 'Cancel') }}
556
+ </UButton>
557
+ <UButton
558
+ :disabled="!widgetForm.type || !widgetForm.label || !widgetForm.config.url"
559
+ @click="saveWidget"
560
+ >
561
+ {{ editingIndex === -1 ? t('common.add', 'Add') : t('common.save', 'Save') }}
562
+ </UButton>
563
+ </div>
564
+ </template>
565
+ </UModal>
566
+ </div>
567
+ </template>
@@ -0,0 +1,51 @@
1
+ import { getDb } from '../../db/index'
2
+ import { readProjectConfig } from '../../utils/project-config.util'
3
+
4
+ const DB_KEYS = [
5
+ 'branding_name',
6
+ 'branding_subtitle',
7
+ 'branding_logo_url',
8
+ 'theme_primary',
9
+ 'theme_neutral',
10
+ 'custom_widgets',
11
+ ] as const
12
+
13
+ export default defineEventHandler(async (event) => {
14
+ const user = event.context.user
15
+ if (!user?.is_super_admin) throw createError({ statusCode: 403, message: 'Forbidden' })
16
+
17
+ const db = getDb()
18
+ const rows = await db('settings').whereIn('key', [...DB_KEYS]).select('key', 'value')
19
+ const map: Record<string, string> = {}
20
+ for (const r of rows) map[r.key] = r.value || ''
21
+
22
+ let customWidgets: unknown[] = []
23
+ if (map.custom_widgets) {
24
+ try {
25
+ const parsed = JSON.parse(map.custom_widgets)
26
+ customWidgets = Array.isArray(parsed) ? parsed : []
27
+ }
28
+ catch {
29
+ // DB value is corrupted — return empty array rather than crashing
30
+ customWidgets = []
31
+ }
32
+ }
33
+
34
+ // Indicate whether a config file is active (its values override DB)
35
+ const fileConfig = readProjectConfig()
36
+ const hasConfigFile = !!(fileConfig.branding || fileConfig.theme || fileConfig.widgets)
37
+
38
+ return {
39
+ hasConfigFile,
40
+ branding: {
41
+ name: map.branding_name || '',
42
+ subtitle: map.branding_subtitle || '',
43
+ logoUrl: map.branding_logo_url || '',
44
+ },
45
+ theme: {
46
+ primary: map.theme_primary || '',
47
+ neutral: map.theme_neutral || '',
48
+ },
49
+ customWidgets,
50
+ }
51
+ })
@@ -0,0 +1,70 @@
1
+ import { getDb } from '../../db/index'
2
+ import type { IBrandingConfig, IThemeConfig, ICustomWidgetDef } from '../../../interfaces/project-config.interface'
3
+ import {
4
+ validateBranding,
5
+ validateTheme,
6
+ validateCustomWidgets,
7
+ } from '../../utils/customization-validation.util'
8
+
9
+ interface ICustomizationBody {
10
+ branding?: IBrandingConfig
11
+ theme?: IThemeConfig
12
+ customWidgets?: ICustomWidgetDef[]
13
+ }
14
+
15
+ async function upsert(db: ReturnType<typeof getDb>, key: string, value: string) {
16
+ const existing = await db('settings').where({ key }).first()
17
+ if (existing) {
18
+ await db('settings').where({ key }).update({ value, updated_at: db.fn.now() })
19
+ }
20
+ else {
21
+ await db('settings').insert({ key, value })
22
+ }
23
+ }
24
+
25
+ export default defineEventHandler(async (event) => {
26
+ const user = event.context.user
27
+ if (!user?.is_super_admin) throw createError({ statusCode: 403, message: 'Forbidden' })
28
+
29
+ const body = await readBody<ICustomizationBody>(event)
30
+ if (!body || typeof body !== 'object')
31
+ throw createError({ statusCode: 400, message: 'Invalid request body' })
32
+
33
+ // ── Validate inputs before touching the DB ────────────────────────────────
34
+
35
+ if (body.branding !== undefined) {
36
+ const err = validateBranding(body.branding)
37
+ if (err) throw createError({ statusCode: 422, message: err })
38
+ }
39
+
40
+ if (body.theme !== undefined) {
41
+ const err = validateTheme(body.theme)
42
+ if (err) throw createError({ statusCode: 422, message: err })
43
+ }
44
+
45
+ if (body.customWidgets !== undefined) {
46
+ const err = validateCustomWidgets(body.customWidgets)
47
+ if (err) throw createError({ statusCode: 422, message: err })
48
+ }
49
+
50
+ // ── Persist ───────────────────────────────────────────────────────────────
51
+
52
+ const db = getDb()
53
+
54
+ if (body.branding !== undefined) {
55
+ await upsert(db, 'branding_name', body.branding.name ?? '')
56
+ await upsert(db, 'branding_subtitle', body.branding.subtitle ?? '')
57
+ await upsert(db, 'branding_logo_url', body.branding.logoUrl ?? '')
58
+ }
59
+
60
+ if (body.theme !== undefined) {
61
+ await upsert(db, 'theme_primary', body.theme.primary ?? '')
62
+ await upsert(db, 'theme_neutral', body.theme.neutral ?? '')
63
+ }
64
+
65
+ if (body.customWidgets !== undefined) {
66
+ await upsert(db, 'custom_widgets', JSON.stringify(body.customWidgets))
67
+ }
68
+
69
+ return { ok: true }
70
+ })
@@ -0,0 +1,55 @@
1
+ import { getDb } from '../db/index'
2
+ import { readProjectConfig } from '../utils/project-config.util'
3
+ import type { IBrandingConfig, IThemeConfig, ICustomWidgetDef } from '../../interfaces/project-config.interface'
4
+
5
+ // This endpoint is intentionally public: branding and theme are needed before login
6
+ // (login page styling, loading indicator colour). Widget *metadata* (type/label/icon/sizes)
7
+ // is safe to expose publicly — widget URLs are only rendered inside the authenticated dashboard.
8
+
9
+ export default defineEventHandler(async () => {
10
+ // ── Read DB settings (base values) ─────────────────────────────────────────
11
+ const db = getDb()
12
+ const rows = await db('settings')
13
+ .whereIn('key', ['branding_name', 'branding_subtitle', 'branding_logo_url', 'theme_primary', 'theme_neutral', 'custom_widgets'])
14
+ .select('key', 'value')
15
+
16
+ const map: Record<string, string> = {}
17
+ for (const r of rows) map[r.key] = r.value || ''
18
+
19
+ const dbBranding: IBrandingConfig = {
20
+ name: map.branding_name || undefined,
21
+ subtitle: map.branding_subtitle || undefined,
22
+ logoUrl: map.branding_logo_url || undefined,
23
+ }
24
+ const dbTheme: IThemeConfig = {
25
+ primary: map.theme_primary || undefined,
26
+ neutral: map.theme_neutral || undefined,
27
+ }
28
+ let dbWidgets: ICustomWidgetDef[] = []
29
+ if (map.custom_widgets) {
30
+ try {
31
+ const parsed = JSON.parse(map.custom_widgets)
32
+ dbWidgets = Array.isArray(parsed) ? parsed : []
33
+ }
34
+ catch {
35
+ // DB value is corrupted — degrade gracefully
36
+ dbWidgets = []
37
+ }
38
+ }
39
+
40
+ // ── Merge with config file (file takes priority) ────────────────────────────
41
+ const fileConfig = readProjectConfig()
42
+
43
+ const branding: IBrandingConfig = { ...dbBranding, ...fileConfig.branding }
44
+ const theme: IThemeConfig = { ...dbTheme, ...fileConfig.theme }
45
+ const customWidgets: ICustomWidgetDef[] = fileConfig.widgets?.custom ?? dbWidgets
46
+
47
+ const hasBranding = Object.values(branding).some(Boolean)
48
+ const hasTheme = Object.values(theme).some(Boolean)
49
+
50
+ return {
51
+ branding: hasBranding ? branding : null,
52
+ theme: hasTheme ? theme : null,
53
+ widgets: customWidgets.length ? { custom: customWidgets } : null,
54
+ }
55
+ })
@@ -0,0 +1,76 @@
1
+ import type { IBrandingConfig, IThemeConfig, ICustomWidgetDef } from '../../interfaces/project-config.interface'
2
+
3
+ // ── Allowed colour sets ─────────────────────────────────────────────────────
4
+
5
+ export const ALLOWED_PRIMARY_COLORS = new Set([
6
+ 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal',
7
+ 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose',
8
+ ])
9
+
10
+ export const ALLOWED_NEUTRAL_COLORS = new Set(['slate', 'gray', 'zinc', 'neutral', 'stone'])
11
+
12
+ // ── Helpers ─────────────────────────────────────────────────────────────────
13
+
14
+ // Only http / https URLs are accepted — blocks javascript:, data:, file:, vbscript:, etc.
15
+ const SAFE_URL_RE = /^https?:\/\/.+/i
16
+
17
+ export function isSafeUrl(url: string | undefined): boolean {
18
+ if (!url) return true
19
+ return SAFE_URL_RE.test(url) && url.length <= 2048
20
+ }
21
+
22
+ // Widget type must be kebab-case identifiers (e.g. "my-widget", "metrics")
23
+ const WIDGET_TYPE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
24
+
25
+ // ── Validators ──────────────────────────────────────────────────────────────
26
+
27
+ export function validateBranding(branding: IBrandingConfig): string | null {
28
+ if (branding.name !== undefined && branding.name.length > 100)
29
+ return 'branding.name must be 100 characters or fewer'
30
+ if (branding.subtitle !== undefined && branding.subtitle.length > 200)
31
+ return 'branding.subtitle must be 200 characters or fewer'
32
+ if (branding.logoUrl && !isSafeUrl(branding.logoUrl))
33
+ return 'branding.logoUrl must be a valid http/https URL (max 2048 chars)'
34
+ return null
35
+ }
36
+
37
+ export function validateTheme(theme: IThemeConfig): string | null {
38
+ if (theme.primary && !ALLOWED_PRIMARY_COLORS.has(theme.primary))
39
+ return `theme.primary "${theme.primary}" is not a valid Tailwind color name`
40
+ if (theme.neutral && !ALLOWED_NEUTRAL_COLORS.has(theme.neutral))
41
+ return `theme.neutral "${theme.neutral}" is not a valid Tailwind neutral color`
42
+ return null
43
+ }
44
+
45
+ export function validateCustomWidgets(widgets: ICustomWidgetDef[]): string | null {
46
+ if (!Array.isArray(widgets))
47
+ return 'widgets.custom must be an array'
48
+ if (widgets.length > 20)
49
+ return 'Maximum 20 custom widgets allowed'
50
+
51
+ const seenTypes = new Set<string>()
52
+
53
+ for (const w of widgets) {
54
+ if (!w.type || !WIDGET_TYPE_RE.test(w.type))
55
+ return `Widget type "${w.type}" must match pattern [a-z0-9-] (e.g. "my-widget")`
56
+ if (w.type.length > 64)
57
+ return `Widget type "${w.type}" exceeds 64 characters`
58
+ if (seenTypes.has(w.type))
59
+ return `Duplicate widget type: "${w.type}"`
60
+ seenTypes.add(w.type)
61
+
62
+ if (!w.label || typeof w.label !== 'string' || w.label.length > 100)
63
+ return `Widget "${w.type}": label must be 1–100 characters`
64
+ if (w.description !== undefined && w.description.length > 300)
65
+ return `Widget "${w.type}": description exceeds 300 characters`
66
+ if (w.icon !== undefined && w.icon.length > 100)
67
+ return `Widget "${w.type}": icon name exceeds 100 characters`
68
+
69
+ if (!w.config || w.config.kind !== 'iframe')
70
+ return `Widget "${w.type}": only "iframe" kind is supported`
71
+ if (!w.config.url || !isSafeUrl(w.config.url))
72
+ return `Widget "${w.type}": URL must be a valid http/https URL (max 2048 chars)`
73
+ }
74
+
75
+ return null
76
+ }