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 +1 -1
- package/src/app.vue +24 -1
- package/src/assets/locales/en.json +41 -1
- package/src/components/dashboard/ProjectWidgetGrid.vue +8 -5
- package/src/components/dashboard/WidgetGrid.vue +8 -5
- package/src/components/dashboard/WidgetPicker.vue +22 -10
- package/src/components/dashboard/widgets/CustomIframeWidget.vue +56 -0
- package/src/composables/useModuleConfig.ts +24 -0
- package/src/composables/useWidgetRegistry.ts +38 -0
- package/src/interfaces/dashboard.interface.ts +1 -1
- package/src/interfaces/project-config.interface.ts +38 -7
- package/src/layouts/default.vue +28 -4
- package/src/pages/admin/customization.vue +567 -0
- package/src/server/api/admin/customization.get.ts +51 -0
- package/src/server/api/admin/customization.post.ts +70 -0
- package/src/server/api/app-config.get.ts +55 -0
- package/src/server/utils/customization-validation.util.ts +76 -0
package/package.json
CHANGED
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="
|
|
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
|
|
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:
|
|
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
|
|
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 &&
|
|
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
|
|
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:
|
|
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
|
|
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 &&
|
|
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(
|
|
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] ??
|
|
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
|
|
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
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
}
|
package/src/layouts/default.vue
CHANGED
|
@@ -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
|
-
|
|
42
|
+
{{ appName }}
|
|
36
43
|
</h1>
|
|
37
44
|
<p class="text-xs text-gray-400">
|
|
38
|
-
|
|
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">
|
|
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
|
+
}
|