i18n-dashboard 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -5
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "A web dashboard to manage vue-i18n translation keys with database persistence",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"bcryptjs": "^2.4.3",
|
|
31
31
|
"commander": "^13.1.0",
|
|
32
32
|
"knex": "^3.1.0",
|
|
33
|
-
"nodemailer": "^
|
|
33
|
+
"nodemailer": "^8.0.3",
|
|
34
34
|
"nuxt": "^3.21.1",
|
|
35
35
|
"vue": "^3.5.13"
|
|
36
36
|
},
|
|
@@ -75,20 +75,23 @@
|
|
|
75
75
|
"localization"
|
|
76
76
|
],
|
|
77
77
|
"license": "MIT",
|
|
78
|
+
"overrides": {
|
|
79
|
+
"flatted": "^3.4.2"
|
|
80
|
+
},
|
|
78
81
|
"devDependencies": {
|
|
79
82
|
"@iconify-json/heroicons": "^1.2.3",
|
|
80
83
|
"@types/node": "^25.3.5",
|
|
81
84
|
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
|
82
85
|
"@typescript-eslint/parser": "^8.57.1",
|
|
83
|
-
"@vitest/coverage-v8": "^4.1.
|
|
84
|
-
"@vitest/ui": "^4.1.
|
|
86
|
+
"@vitest/coverage-v8": "^4.1.1",
|
|
87
|
+
"@vitest/ui": "^4.1.1",
|
|
85
88
|
"@vue/eslint-config-typescript": "^14.7.0",
|
|
86
89
|
"@vue/test-utils": "^2.4.6",
|
|
87
90
|
"eslint": "^10.0.3",
|
|
88
91
|
"eslint-plugin-vue": "^10.8.0",
|
|
89
92
|
"happy-dom": "^20.8.4",
|
|
90
93
|
"husky": "^9.1.7",
|
|
91
|
-
"vitest": "^4.1.
|
|
94
|
+
"vitest": "^4.1.1",
|
|
92
95
|
"vue-eslint-parser": "^10.4.0"
|
|
93
96
|
}
|
|
94
97
|
}
|
package/src/app.vue
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Theme application: update appConfig.ui.colors so the @nuxt/ui colors plugin
|
|
4
|
+
* regenerates --ui-color-primary-* / --ui-color-neutral-* CSS variables.
|
|
5
|
+
* The plugin (node_modules/@nuxt/ui/dist/runtime/plugins/colors.js) is reactive —
|
|
6
|
+
* any change to appConfig.ui.colors triggers an automatic style tag update.
|
|
7
|
+
*
|
|
8
|
+
* useFetch is SSR-compatible, so the correct color is applied before the page
|
|
9
|
+
* reaches the browser (no flash of the default colour).
|
|
10
|
+
*/
|
|
11
|
+
const { theme } = useModuleConfig()
|
|
12
|
+
const appConfig = useAppConfig()
|
|
13
|
+
|
|
14
|
+
watch(
|
|
15
|
+
theme,
|
|
16
|
+
(t) => {
|
|
17
|
+
if (t?.primary) appConfig.ui.colors.primary = t.primary
|
|
18
|
+
if (t?.neutral) appConfig.ui.colors.neutral = t.neutral
|
|
19
|
+
},
|
|
20
|
+
{ immediate: true },
|
|
21
|
+
)
|
|
22
|
+
</script>
|
|
23
|
+
|
|
1
24
|
<template>
|
|
2
25
|
<UApp>
|
|
3
26
|
<NuxtLoadingIndicator
|
|
4
|
-
color="
|
|
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)
|