i18n-dashboard 0.15.4 → 0.16.1
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/assets/locales/en.json +1 -0
- package/src/components/dashboard/ProjectWidgetGrid.vue +217 -0
- package/src/components/dashboard/WidgetPicker.vue +6 -1
- package/src/components/dashboard/widgets/LanguagesCoverageWidget.vue +1 -1
- package/src/components/dashboard/widgets/StatWidget.vue +2 -2
- package/src/composables/useProjectDashboard.ts +101 -0
- package/src/consts/dashboard.const.ts +13 -0
- package/src/pages/projects/[id]/index.vue +3 -336
- package/src/pages/projects/[id]/languages.vue +39 -2
- package/src/server/api/dashboard/project-layout.get.ts +22 -0
- package/src/server/api/dashboard/project-layout.post.ts +21 -0
- package/src/server/api/stats/global.get.ts +1 -1
- package/src/server/api/stats.get.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
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
|
}
|
|
@@ -112,6 +112,7 @@
|
|
|
112
112
|
"review.awaiting_approval": "Awaiting approval",
|
|
113
113
|
"review.translations_approved": "translation(s) approved",
|
|
114
114
|
"languages.deleted": "Language deleted",
|
|
115
|
+
"languages.translate_missing": "Translate missing",
|
|
115
116
|
"languages.translating": "Translating",
|
|
116
117
|
"languages.translate_done": "Translation complete",
|
|
117
118
|
"languages.with_errors": "with errors",
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { TWidgetType } from '../../types/dashboard.type'
|
|
3
|
+
import { WIDGET_REGISTRY, WIDGET_SIZE_CLASSES } from '../../consts/dashboard.const'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
projectId: number
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { t } = useT()
|
|
10
|
+
const {
|
|
11
|
+
layout,
|
|
12
|
+
editing,
|
|
13
|
+
localLayout,
|
|
14
|
+
saving,
|
|
15
|
+
startEditing,
|
|
16
|
+
cancelEditing,
|
|
17
|
+
saveLayout,
|
|
18
|
+
onDragStart,
|
|
19
|
+
onDragOver,
|
|
20
|
+
onDragEnd,
|
|
21
|
+
removeWidget,
|
|
22
|
+
addWidget,
|
|
23
|
+
resizeWidget,
|
|
24
|
+
updateWidgetConfig,
|
|
25
|
+
} = useProjectDashboard(props.projectId)
|
|
26
|
+
|
|
27
|
+
const showPicker = ref(false)
|
|
28
|
+
const configIndex = ref(-1)
|
|
29
|
+
|
|
30
|
+
const activeLayout = computed(() => editing.value ? localLayout.value : layout.value)
|
|
31
|
+
|
|
32
|
+
function sizeClass(size: string) {
|
|
33
|
+
return WIDGET_SIZE_CLASSES[size as keyof typeof WIDGET_SIZE_CLASSES] ?? 'col-span-1 row-span-1'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function widgetComponent(type: TWidgetType) {
|
|
37
|
+
switch (type) {
|
|
38
|
+
case 'stat-keys':
|
|
39
|
+
case 'stat-coverage':
|
|
40
|
+
case 'stat-languages':
|
|
41
|
+
case 'stat-unused':
|
|
42
|
+
return resolveComponent('DashboardWidgetsStatWidget')
|
|
43
|
+
case 'projects':
|
|
44
|
+
return resolveComponent('DashboardWidgetsProjectsWidget')
|
|
45
|
+
case 'languages-coverage':
|
|
46
|
+
return resolveComponent('DashboardWidgetsLanguagesCoverageWidget')
|
|
47
|
+
case 'last-activity':
|
|
48
|
+
return resolveComponent('DashboardWidgetsActivityWidget')
|
|
49
|
+
case 'review-queue':
|
|
50
|
+
return resolveComponent('DashboardWidgetsReviewWidget')
|
|
51
|
+
default:
|
|
52
|
+
return 'div'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function onSaveConfig(patch: { dataSource?: any; title?: string | undefined }) {
|
|
57
|
+
updateWidgetConfig(configIndex.value, patch)
|
|
58
|
+
configIndex.value = -1
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<div>
|
|
64
|
+
<!-- Toolbar -->
|
|
65
|
+
<div class="flex items-center justify-end mb-6 gap-2">
|
|
66
|
+
<template v-if="!editing">
|
|
67
|
+
<UButton
|
|
68
|
+
variant="ghost"
|
|
69
|
+
color="neutral"
|
|
70
|
+
icon="i-heroicons-pencil-square"
|
|
71
|
+
@click="startEditing"
|
|
72
|
+
>
|
|
73
|
+
{{ t('dashboard.edit', 'Edit') }}
|
|
74
|
+
</UButton>
|
|
75
|
+
</template>
|
|
76
|
+
<template v-else>
|
|
77
|
+
<UButton
|
|
78
|
+
variant="ghost"
|
|
79
|
+
color="neutral"
|
|
80
|
+
@click="cancelEditing"
|
|
81
|
+
>
|
|
82
|
+
{{ t('common.cancel', 'Cancel') }}
|
|
83
|
+
</UButton>
|
|
84
|
+
<UButton
|
|
85
|
+
icon="i-heroicons-plus"
|
|
86
|
+
variant="outline"
|
|
87
|
+
color="neutral"
|
|
88
|
+
@click="showPicker = true"
|
|
89
|
+
>
|
|
90
|
+
{{ t('common.add', 'Add') }}
|
|
91
|
+
</UButton>
|
|
92
|
+
<UButton
|
|
93
|
+
:loading="saving"
|
|
94
|
+
icon="i-heroicons-check"
|
|
95
|
+
@click="saveLayout"
|
|
96
|
+
>
|
|
97
|
+
{{ t('dashboard.done', 'Done') }}
|
|
98
|
+
</UButton>
|
|
99
|
+
</template>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<!-- Empty state -->
|
|
103
|
+
<div
|
|
104
|
+
v-if="activeLayout.length === 0"
|
|
105
|
+
class="border border-dashed border-gray-300 dark:border-gray-700 rounded-xl p-16 text-center"
|
|
106
|
+
>
|
|
107
|
+
<UIcon
|
|
108
|
+
name="i-heroicons-squares-2x2"
|
|
109
|
+
class="text-5xl text-gray-300 dark:text-gray-600 mb-3"
|
|
110
|
+
/>
|
|
111
|
+
<p class="text-gray-400 font-medium">
|
|
112
|
+
{{ t('dashboard.no_widgets', 'No widgets') }}
|
|
113
|
+
</p>
|
|
114
|
+
<UButton
|
|
115
|
+
v-if="editing"
|
|
116
|
+
class="mt-4"
|
|
117
|
+
icon="i-heroicons-plus"
|
|
118
|
+
@click="showPicker = true"
|
|
119
|
+
>
|
|
120
|
+
{{ t('dashboard.add_widget', 'Add a widget') }}
|
|
121
|
+
</UButton>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- Widget grid -->
|
|
125
|
+
<div
|
|
126
|
+
v-else
|
|
127
|
+
class="grid grid-cols-4 gap-4 auto-rows-[minmax(140px,auto)]"
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
v-for="(widget, index) in activeLayout"
|
|
131
|
+
:key="widget.id"
|
|
132
|
+
:class="[sizeClass(widget.size), 'relative', editing ? 'cursor-grab' : '']"
|
|
133
|
+
:draggable="editing"
|
|
134
|
+
@dragstart="onDragStart(index)"
|
|
135
|
+
@dragover="onDragOver($event, index)"
|
|
136
|
+
@dragend="onDragEnd"
|
|
137
|
+
>
|
|
138
|
+
<!-- Remove button -->
|
|
139
|
+
<button
|
|
140
|
+
v-if="editing"
|
|
141
|
+
class="absolute -top-2 -left-2 z-10 w-5 h-5 bg-red-500 rounded-full text-white flex items-center justify-center text-xs shadow-sm hover:bg-red-600 transition-colors"
|
|
142
|
+
@click.stop="removeWidget(index)"
|
|
143
|
+
>
|
|
144
|
+
×
|
|
145
|
+
</button>
|
|
146
|
+
|
|
147
|
+
<!-- Size buttons -->
|
|
148
|
+
<div
|
|
149
|
+
v-if="editing"
|
|
150
|
+
class="absolute -top-2 right-0 z-10 flex gap-0.5"
|
|
151
|
+
>
|
|
152
|
+
<button
|
|
153
|
+
v-for="s in WIDGET_REGISTRY[widget.type].sizes"
|
|
154
|
+
:key="s"
|
|
155
|
+
class="px-1 py-0.5 text-xs rounded shadow-sm transition-colors"
|
|
156
|
+
:class="widget.size === s
|
|
157
|
+
? 'bg-primary-500 text-white'
|
|
158
|
+
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'"
|
|
159
|
+
@click.stop="resizeWidget(index, s)"
|
|
160
|
+
>
|
|
161
|
+
{{ s }}
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<!-- Config button -->
|
|
166
|
+
<button
|
|
167
|
+
v-if="editing && WIDGET_REGISTRY[widget.type].hasDataSource"
|
|
168
|
+
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
|
+
@click.stop="configIndex = index"
|
|
170
|
+
>
|
|
171
|
+
<UIcon
|
|
172
|
+
name="i-heroicons-cog-6-tooth"
|
|
173
|
+
class="text-xs"
|
|
174
|
+
/>
|
|
175
|
+
</button>
|
|
176
|
+
|
|
177
|
+
<div :class="editing ? 'animate-wiggle h-full' : 'h-full'">
|
|
178
|
+
<component
|
|
179
|
+
:is="widgetComponent(widget.type)"
|
|
180
|
+
:id="widget.id"
|
|
181
|
+
:type="widget.type"
|
|
182
|
+
:size="widget.size"
|
|
183
|
+
:editing="editing"
|
|
184
|
+
:data-source="widget.dataSource"
|
|
185
|
+
:title="widget.title"
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<!-- Widget picker (exclude 'projects' widget — not relevant in project context) -->
|
|
192
|
+
<DashboardWidgetPicker
|
|
193
|
+
v-model="showPicker"
|
|
194
|
+
:exclude-types="['projects']"
|
|
195
|
+
@add="addWidget"
|
|
196
|
+
/>
|
|
197
|
+
|
|
198
|
+
<!-- Widget config modal -->
|
|
199
|
+
<DashboardWidgetConfigModal
|
|
200
|
+
:open="configIndex !== -1"
|
|
201
|
+
:widget="configIndex !== -1 ? activeLayout[configIndex] : null"
|
|
202
|
+
:index="configIndex"
|
|
203
|
+
@update:open="val => { if (!val) configIndex = -1 }"
|
|
204
|
+
@save="onSaveConfig"
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</template>
|
|
208
|
+
|
|
209
|
+
<style scoped>
|
|
210
|
+
@keyframes wiggle {
|
|
211
|
+
0%, 100% { transform: rotate(-0.5deg); }
|
|
212
|
+
50% { transform: rotate(0.5deg); }
|
|
213
|
+
}
|
|
214
|
+
.animate-wiggle {
|
|
215
|
+
animation: wiggle 0.4s ease-in-out infinite;
|
|
216
|
+
}
|
|
217
|
+
</style>
|
|
@@ -7,6 +7,7 @@ const { t } = useT()
|
|
|
7
7
|
|
|
8
8
|
const props = defineProps<{
|
|
9
9
|
modelValue: boolean
|
|
10
|
+
excludeTypes?: string[]
|
|
10
11
|
}>()
|
|
11
12
|
|
|
12
13
|
const emit = defineEmits<{
|
|
@@ -19,6 +20,10 @@ const open = computed({
|
|
|
19
20
|
set: (v) => emit('update:modelValue', v),
|
|
20
21
|
})
|
|
21
22
|
|
|
23
|
+
const availableWidgets = computed(() =>
|
|
24
|
+
Object.entries(WIDGET_REGISTRY).filter(([type]) => !props.excludeTypes?.includes(type)),
|
|
25
|
+
)
|
|
26
|
+
|
|
22
27
|
const selectedSizes = ref<Record<string, TWidgetSize>>({})
|
|
23
28
|
|
|
24
29
|
function getSelectedSize(type: string): TWidgetSize {
|
|
@@ -42,7 +47,7 @@ function addWidget(type: string) {
|
|
|
42
47
|
<template #body>
|
|
43
48
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-1">
|
|
44
49
|
<div
|
|
45
|
-
v-for="
|
|
50
|
+
v-for="[type, config] in availableWidgets"
|
|
46
51
|
:key="type"
|
|
47
52
|
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"
|
|
48
53
|
>
|
|
@@ -122,7 +122,7 @@ function coverageColor(coverage: number) {
|
|
|
122
122
|
class="font-semibold"
|
|
123
123
|
:class="lang.coverage >= 90 ? 'text-green-600' : lang.coverage >= 60 ? 'text-yellow-500' : 'text-red-500'"
|
|
124
124
|
>
|
|
125
|
-
{{ lang.coverage }}%
|
|
125
|
+
{{ lang.coverage.toFixed(2) }}%
|
|
126
126
|
</span>
|
|
127
127
|
</div>
|
|
128
128
|
<div class="w-full bg-gray-100 dark:bg-gray-800 rounded-full h-1.5">
|
|
@@ -78,14 +78,14 @@ const coverage = computed(() => {
|
|
|
78
78
|
const langs = stats.value?.languages
|
|
79
79
|
if (!langs?.length) return 0
|
|
80
80
|
const total = langs.reduce((sum: number, l: any) => sum + l.coverage, 0)
|
|
81
|
-
return
|
|
81
|
+
return parseFloat((total / langs.length).toFixed(2))
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
const displayValue = computed(() => {
|
|
85
85
|
if (!stats.value) return '—'
|
|
86
86
|
switch (props.type) {
|
|
87
87
|
case 'stat-keys': return stats.value.totalKeys ?? '—'
|
|
88
|
-
case 'stat-coverage': return `${coverage.value}%`
|
|
88
|
+
case 'stat-coverage': return `${coverage.value.toFixed(2)}%`
|
|
89
89
|
case 'stat-languages': return stats.value.languages?.length ?? '—'
|
|
90
90
|
case 'stat-unused': return stats.value.unusedKeys ?? '—'
|
|
91
91
|
default: return '—'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { IWidgetConfig, IWidgetDataSource } from '../interfaces/dashboard.interface'
|
|
2
|
+
import type { TWidgetSize } from '../types/dashboard.type'
|
|
3
|
+
import { DEFAULT_PROJECT_LAYOUT } from '../consts/dashboard.const'
|
|
4
|
+
|
|
5
|
+
export function useProjectDashboard(projectId: number) {
|
|
6
|
+
const { data, refresh } = useAsyncData(
|
|
7
|
+
`project-dashboard-layout-${projectId}`,
|
|
8
|
+
() => $fetch(`/api/dashboard/project-layout?project_id=${projectId}`),
|
|
9
|
+
{ server: false },
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const layout = computed<IWidgetConfig[]>(() => (data.value as IWidgetConfig[]) ?? DEFAULT_PROJECT_LAYOUT(projectId))
|
|
13
|
+
|
|
14
|
+
const editing = ref(false)
|
|
15
|
+
const localLayout = ref<IWidgetConfig[]>([])
|
|
16
|
+
|
|
17
|
+
function startEditing() {
|
|
18
|
+
localLayout.value = [...layout.value]
|
|
19
|
+
editing.value = true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const draggingIndex = ref<number | null>(null)
|
|
23
|
+
|
|
24
|
+
function onDragStart(index: number) {
|
|
25
|
+
draggingIndex.value = index
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function onDragOver(e: DragEvent, index: number) {
|
|
29
|
+
e.preventDefault()
|
|
30
|
+
if (draggingIndex.value === null || draggingIndex.value === index) return
|
|
31
|
+
const arr = [...localLayout.value]
|
|
32
|
+
const [item] = arr.splice(draggingIndex.value, 1)
|
|
33
|
+
arr.splice(index, 0, item)
|
|
34
|
+
localLayout.value = arr
|
|
35
|
+
draggingIndex.value = index
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function onDragEnd() {
|
|
39
|
+
draggingIndex.value = null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function removeWidget(index: number) {
|
|
43
|
+
localLayout.value.splice(index, 1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addWidget(widget: IWidgetConfig) {
|
|
47
|
+
// Force data source to this project when not explicitly set
|
|
48
|
+
const withSource: IWidgetConfig = widget.dataSource
|
|
49
|
+
? widget
|
|
50
|
+
: { ...widget, dataSource: { type: 'project', projectId } }
|
|
51
|
+
localLayout.value.push(withSource)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resizeWidget(index: number, size: TWidgetSize) {
|
|
55
|
+
localLayout.value[index].size = size
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function updateWidgetConfig(index: number, patch: { dataSource?: IWidgetDataSource | undefined; title?: string | undefined }) {
|
|
59
|
+
if (index < 0 || index >= localLayout.value.length) return
|
|
60
|
+
localLayout.value[index] = { ...localLayout.value[index], ...patch }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const saving = ref(false)
|
|
64
|
+
|
|
65
|
+
async function saveLayout() {
|
|
66
|
+
saving.value = true
|
|
67
|
+
try {
|
|
68
|
+
await $fetch('/api/dashboard/project-layout', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
body: { project_id: projectId, widgets: localLayout.value },
|
|
71
|
+
})
|
|
72
|
+
await refresh()
|
|
73
|
+
editing.value = false
|
|
74
|
+
} finally {
|
|
75
|
+
saving.value = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cancelEditing() {
|
|
80
|
+
editing.value = false
|
|
81
|
+
localLayout.value = []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
layout,
|
|
86
|
+
editing,
|
|
87
|
+
localLayout,
|
|
88
|
+
saving,
|
|
89
|
+
draggingIndex,
|
|
90
|
+
startEditing,
|
|
91
|
+
cancelEditing,
|
|
92
|
+
saveLayout,
|
|
93
|
+
onDragStart,
|
|
94
|
+
onDragOver,
|
|
95
|
+
onDragEnd,
|
|
96
|
+
removeWidget,
|
|
97
|
+
addWidget,
|
|
98
|
+
resizeWidget,
|
|
99
|
+
updateWidgetConfig,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -84,6 +84,19 @@ export const WIDGET_REGISTRY: Record<TWidgetType, { label: string; description:
|
|
|
84
84
|
},
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export function DEFAULT_PROJECT_LAYOUT(projectId: number): IWidgetConfig[] {
|
|
88
|
+
const ds = { type: 'project' as const, projectId }
|
|
89
|
+
return [
|
|
90
|
+
{ id: 'proj-default-1', type: 'stat-keys', size: 'sm', dataSource: ds },
|
|
91
|
+
{ id: 'proj-default-2', type: 'stat-coverage', size: 'sm', dataSource: ds },
|
|
92
|
+
{ id: 'proj-default-3', type: 'stat-languages', size: 'sm', dataSource: ds },
|
|
93
|
+
{ id: 'proj-default-4', type: 'stat-unused', size: 'sm', dataSource: ds },
|
|
94
|
+
{ id: 'proj-default-5', type: 'languages-coverage', size: 'wide', dataSource: ds },
|
|
95
|
+
{ id: 'proj-default-6', type: 'last-activity', size: 'md', dataSource: ds },
|
|
96
|
+
{ id: 'proj-default-7', type: 'review-queue', size: 'md', dataSource: ds },
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
export const DEFAULT_LAYOUT: IWidgetConfig[] = [
|
|
88
101
|
{ id: 'default-1', type: 'stat-keys', size: 'sm' },
|
|
89
102
|
{ id: 'default-2', type: 'stat-coverage', size: 'sm' },
|
|
@@ -1,343 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="p-6
|
|
3
|
-
<
|
|
4
|
-
<div>
|
|
5
|
-
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
6
|
-
{{ t('dashboard.title', 'Dashboard') }}
|
|
7
|
-
</h1>
|
|
8
|
-
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
|
|
9
|
-
{{ currentProject?.name }} · {{ currentProject?.root_path }}
|
|
10
|
-
</p>
|
|
11
|
-
</div>
|
|
12
|
-
</div>
|
|
13
|
-
|
|
14
|
-
<!-- Stats row -->
|
|
15
|
-
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
16
|
-
<UCard
|
|
17
|
-
v-for="stat in topStats"
|
|
18
|
-
:key="stat.label"
|
|
19
|
-
>
|
|
20
|
-
<div class="flex items-center gap-3">
|
|
21
|
-
<div
|
|
22
|
-
class="p-2 rounded-lg"
|
|
23
|
-
:class="stat.bg"
|
|
24
|
-
>
|
|
25
|
-
<UIcon
|
|
26
|
-
:name="stat.icon"
|
|
27
|
-
class="text-xl"
|
|
28
|
-
:class="stat.color"
|
|
29
|
-
/>
|
|
30
|
-
</div>
|
|
31
|
-
<div>
|
|
32
|
-
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
33
|
-
{{ stat.label }}
|
|
34
|
-
</p>
|
|
35
|
-
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
36
|
-
<span v-if="pending">—</span>
|
|
37
|
-
<span v-else>{{ stat.value }}</span>
|
|
38
|
-
</p>
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
</UCard>
|
|
42
|
-
</div>
|
|
43
|
-
|
|
44
|
-
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
45
|
-
<!-- Coverage -->
|
|
46
|
-
<UCard class="lg:col-span-2">
|
|
47
|
-
<template #header>
|
|
48
|
-
<div class="flex items-center justify-between">
|
|
49
|
-
<h2 class="font-semibold text-gray-900 dark:text-white">
|
|
50
|
-
{{ t('dashboard.coverage_by_language', 'Coverage by language') }}
|
|
51
|
-
</h2>
|
|
52
|
-
<UButton
|
|
53
|
-
:to="`/projects/${projectId}/translations`"
|
|
54
|
-
variant="ghost"
|
|
55
|
-
size="xs"
|
|
56
|
-
trailing-icon="i-heroicons-arrow-right"
|
|
57
|
-
color="neutral"
|
|
58
|
-
>
|
|
59
|
-
{{ t('dashboard.see_all', 'See all') }}
|
|
60
|
-
</UButton>
|
|
61
|
-
</div>
|
|
62
|
-
</template>
|
|
63
|
-
<div
|
|
64
|
-
v-if="pending"
|
|
65
|
-
class="space-y-4"
|
|
66
|
-
>
|
|
67
|
-
<USkeleton
|
|
68
|
-
v-for="i in 3"
|
|
69
|
-
:key="i"
|
|
70
|
-
class="h-14"
|
|
71
|
-
/>
|
|
72
|
-
</div>
|
|
73
|
-
<div
|
|
74
|
-
v-else-if="!stats?.languages?.length"
|
|
75
|
-
class="text-center py-10"
|
|
76
|
-
>
|
|
77
|
-
<UIcon
|
|
78
|
-
name="i-heroicons-flag"
|
|
79
|
-
class="text-4xl text-gray-300 mb-2"
|
|
80
|
-
/>
|
|
81
|
-
<p class="text-gray-400 text-sm">
|
|
82
|
-
{{ t('languages.none', 'No language configured') }}
|
|
83
|
-
</p>
|
|
84
|
-
<UButton
|
|
85
|
-
:to="`/projects/${projectId}/languages`"
|
|
86
|
-
size="sm"
|
|
87
|
-
class="mt-3"
|
|
88
|
-
>
|
|
89
|
-
{{ t('languages.add', 'Add a language') }}
|
|
90
|
-
</UButton>
|
|
91
|
-
</div>
|
|
92
|
-
<div
|
|
93
|
-
v-else
|
|
94
|
-
class="space-y-4"
|
|
95
|
-
>
|
|
96
|
-
<div
|
|
97
|
-
v-for="lang in stats.languages"
|
|
98
|
-
:key="lang.code"
|
|
99
|
-
>
|
|
100
|
-
<div class="flex items-center justify-between mb-1.5">
|
|
101
|
-
<div class="flex items-center gap-2">
|
|
102
|
-
<span class="font-medium text-sm text-gray-800 dark:text-gray-200">{{ findLanguage(lang.code)?.nativeName || lang.name }}</span>
|
|
103
|
-
<UBadge
|
|
104
|
-
size="xs"
|
|
105
|
-
variant="outline"
|
|
106
|
-
color="neutral"
|
|
107
|
-
>
|
|
108
|
-
{{ lang.code }}
|
|
109
|
-
</UBadge>
|
|
110
|
-
<UBadge
|
|
111
|
-
v-if="lang.is_default"
|
|
112
|
-
size="xs"
|
|
113
|
-
color="primary"
|
|
114
|
-
>
|
|
115
|
-
{{ t('languages.default_badge', 'Default') }}
|
|
116
|
-
</UBadge>
|
|
117
|
-
</div>
|
|
118
|
-
<span
|
|
119
|
-
class="text-sm font-semibold"
|
|
120
|
-
:class="lang.coverage >= 80 ? 'text-green-600' : lang.coverage >= 50 ? 'text-yellow-600' : 'text-red-500'"
|
|
121
|
-
>
|
|
122
|
-
{{ lang.coverage }}%
|
|
123
|
-
</span>
|
|
124
|
-
</div>
|
|
125
|
-
<div class="h-2.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden flex">
|
|
126
|
-
<div
|
|
127
|
-
class="bg-green-500 transition-all duration-500"
|
|
128
|
-
:style="{ width: `${pct(lang.approved, lang.total)}%` }"
|
|
129
|
-
/>
|
|
130
|
-
<div
|
|
131
|
-
class="bg-blue-400 transition-all duration-500"
|
|
132
|
-
:style="{ width: `${pct(lang.reviewed, lang.total)}%` }"
|
|
133
|
-
/>
|
|
134
|
-
<div
|
|
135
|
-
class="bg-yellow-400 transition-all duration-500"
|
|
136
|
-
:style="{ width: `${pct(lang.draft, lang.total)}%` }"
|
|
137
|
-
/>
|
|
138
|
-
</div>
|
|
139
|
-
<div class="flex gap-4 mt-1 text-xs text-gray-400">
|
|
140
|
-
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />{{ t('status.approved', 'Approved') }} ({{ lang.approved }})</span>
|
|
141
|
-
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-blue-400 inline-block" />{{ t('status.reviewed', 'Reviewed') }} ({{ lang.reviewed }})</span>
|
|
142
|
-
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-yellow-400 inline-block" />{{ t('status.draft', 'Draft') }} ({{ lang.draft }})</span>
|
|
143
|
-
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 inline-block" />{{ t('status.missing', 'Missing') }} ({{ lang.missing }})</span>
|
|
144
|
-
</div>
|
|
145
|
-
</div>
|
|
146
|
-
</div>
|
|
147
|
-
</UCard>
|
|
148
|
-
|
|
149
|
-
<!-- Activity -->
|
|
150
|
-
<UCard>
|
|
151
|
-
<template #header>
|
|
152
|
-
<h2 class="font-semibold text-gray-900 dark:text-white">
|
|
153
|
-
{{ t('dashboard.recent_activity', 'Recent activity') }}
|
|
154
|
-
</h2>
|
|
155
|
-
</template>
|
|
156
|
-
<div
|
|
157
|
-
v-if="pending"
|
|
158
|
-
class="space-y-3"
|
|
159
|
-
>
|
|
160
|
-
<USkeleton
|
|
161
|
-
v-for="i in 5"
|
|
162
|
-
:key="i"
|
|
163
|
-
class="h-10"
|
|
164
|
-
/>
|
|
165
|
-
</div>
|
|
166
|
-
<div
|
|
167
|
-
v-else-if="!stats?.recentActivity?.length"
|
|
168
|
-
class="text-center py-8"
|
|
169
|
-
>
|
|
170
|
-
<UIcon
|
|
171
|
-
name="i-heroicons-clock"
|
|
172
|
-
class="text-4xl text-gray-300 mb-2"
|
|
173
|
-
/>
|
|
174
|
-
<p class="text-gray-400 text-sm">
|
|
175
|
-
{{ t('dashboard.no_activity', 'No recent activity') }}
|
|
176
|
-
</p>
|
|
177
|
-
</div>
|
|
178
|
-
<div
|
|
179
|
-
v-else
|
|
180
|
-
class="space-y-1 overflow-y-auto max-h-72"
|
|
181
|
-
>
|
|
182
|
-
<div
|
|
183
|
-
v-for="activity in stats.recentActivity"
|
|
184
|
-
:key="activity.id"
|
|
185
|
-
class="flex gap-2 items-start p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
186
|
-
>
|
|
187
|
-
<UIcon
|
|
188
|
-
:name="activity.changed_by === 'google-translate' ? 'i-heroicons-sparkles' : activity.changed_by === 'sync' ? 'i-heroicons-arrow-path' : 'i-heroicons-pencil'"
|
|
189
|
-
class="text-sm mt-0.5 shrink-0"
|
|
190
|
-
:class="activity.changed_by === 'google-translate' ? 'text-yellow-500' : activity.changed_by === 'sync' ? 'text-blue-500' : 'text-gray-400'"
|
|
191
|
-
/>
|
|
192
|
-
<div class="min-w-0 flex-1">
|
|
193
|
-
<p class="text-xs font-mono font-medium text-gray-700 dark:text-gray-300 truncate">
|
|
194
|
-
{{ activity.key }}
|
|
195
|
-
</p>
|
|
196
|
-
<p class="text-xs text-gray-400">
|
|
197
|
-
{{ activity.language_code }} · {{ formatRelative(activity.changed_at) }}
|
|
198
|
-
</p>
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
201
|
-
</div>
|
|
202
|
-
</UCard>
|
|
203
|
-
</div>
|
|
204
|
-
|
|
205
|
-
<!-- Code snippet card (shown when advanced features are enabled) -->
|
|
206
|
-
<UCard v-if="currentProject && (currentProject.enable_number_formats || currentProject.enable_datetime_formats || currentProject.enable_modifiers)">
|
|
207
|
-
<template #header>
|
|
208
|
-
<div class="flex items-center justify-between">
|
|
209
|
-
<div class="flex items-center gap-2">
|
|
210
|
-
<UIcon
|
|
211
|
-
name="i-heroicons-code-bracket-square"
|
|
212
|
-
class="text-purple-500"
|
|
213
|
-
/>
|
|
214
|
-
<h2 class="font-semibold text-gray-900 dark:text-white">
|
|
215
|
-
{{ t('dashboard.generated_config', 'Generated vue-i18n configuration') }}
|
|
216
|
-
</h2>
|
|
217
|
-
</div>
|
|
218
|
-
<UButton
|
|
219
|
-
color="neutral"
|
|
220
|
-
variant="ghost"
|
|
221
|
-
size="xs"
|
|
222
|
-
icon="i-heroicons-clipboard"
|
|
223
|
-
@click="copySnippet"
|
|
224
|
-
>
|
|
225
|
-
{{ t('dashboard.copy', 'Copy') }}
|
|
226
|
-
</UButton>
|
|
227
|
-
</div>
|
|
228
|
-
</template>
|
|
229
|
-
<div
|
|
230
|
-
v-if="snippetPending"
|
|
231
|
-
class="space-y-2"
|
|
232
|
-
>
|
|
233
|
-
<USkeleton class="h-4 w-full" />
|
|
234
|
-
<USkeleton class="h-4 w-3/4" />
|
|
235
|
-
<USkeleton class="h-4 w-5/6" />
|
|
236
|
-
</div>
|
|
237
|
-
<div v-else-if="snippetData?.snippet">
|
|
238
|
-
<pre class="text-xs font-mono text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 overflow-auto max-h-64">{{ snippetData.snippet }}</pre>
|
|
239
|
-
</div>
|
|
240
|
-
<div
|
|
241
|
-
v-else
|
|
242
|
-
class="text-sm text-gray-400 italic"
|
|
243
|
-
>
|
|
244
|
-
{{ t('dashboard.no_config_generated', 'No configuration generated for this project.') }}
|
|
245
|
-
</div>
|
|
246
|
-
</UCard>
|
|
247
|
-
|
|
248
|
-
<!-- Quick actions -->
|
|
249
|
-
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
250
|
-
<UButton
|
|
251
|
-
block
|
|
252
|
-
variant="outline"
|
|
253
|
-
color="neutral"
|
|
254
|
-
icon="i-heroicons-plus-circle"
|
|
255
|
-
:to="`/projects/${projectId}/translations`"
|
|
256
|
-
>
|
|
257
|
-
{{ t('dashboard.new_key', 'New key') }}
|
|
258
|
-
</UButton>
|
|
259
|
-
<UButton
|
|
260
|
-
block
|
|
261
|
-
variant="outline"
|
|
262
|
-
color="neutral"
|
|
263
|
-
icon="i-heroicons-flag"
|
|
264
|
-
:to="`/projects/${projectId}/languages`"
|
|
265
|
-
>
|
|
266
|
-
{{ t('nav.languages', 'Languages') }}
|
|
267
|
-
</UButton>
|
|
268
|
-
<UButton
|
|
269
|
-
block
|
|
270
|
-
variant="outline"
|
|
271
|
-
color="neutral"
|
|
272
|
-
icon="i-heroicons-exclamation-triangle"
|
|
273
|
-
:to="`/projects/${projectId}/translations?status=unused`"
|
|
274
|
-
>
|
|
275
|
-
{{ t('nav.unused', 'Unused') }}
|
|
276
|
-
<UBadge
|
|
277
|
-
v-if="stats?.unusedKeys"
|
|
278
|
-
size="xs"
|
|
279
|
-
color="warning"
|
|
280
|
-
class="ml-1"
|
|
281
|
-
>
|
|
282
|
-
{{ stats.unusedKeys }}
|
|
283
|
-
</UBadge>
|
|
284
|
-
</UButton>
|
|
285
|
-
<UButton
|
|
286
|
-
block
|
|
287
|
-
variant="outline"
|
|
288
|
-
color="neutral"
|
|
289
|
-
icon="i-heroicons-rectangle-stack"
|
|
290
|
-
to="/projects"
|
|
291
|
-
>
|
|
292
|
-
{{ t('nav.projects', 'Projects') }}
|
|
293
|
-
</UButton>
|
|
294
|
-
</div>
|
|
2
|
+
<div class="p-6">
|
|
3
|
+
<DashboardProjectWidgetGrid :project-id="projectId" />
|
|
295
4
|
</div>
|
|
296
5
|
</template>
|
|
297
6
|
|
|
298
7
|
<script setup lang="ts">
|
|
299
8
|
const route = useRoute()
|
|
300
|
-
const projectId = computed(() => route.params.id)
|
|
301
|
-
const { currentProject } = useProject()
|
|
302
|
-
const { findLanguage } = useLanguages()
|
|
303
|
-
const { stats, pending } = useStats()
|
|
304
|
-
const toast = useToast()
|
|
305
|
-
const { t } = useT()
|
|
306
|
-
|
|
307
|
-
const { data: snippetData, pending: snippetPending } = useFetch<any>('/api/formats/snippet', {
|
|
308
|
-
query: computed(() => ({ project_id: projectId.value })),
|
|
309
|
-
default: () => null,
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
async function copySnippet() {
|
|
313
|
-
if (!snippetData.value?.snippet) return
|
|
314
|
-
await navigator.clipboard.writeText(snippetData.value.snippet)
|
|
315
|
-
toast.add({ title: t('common.copied', 'Copied!'), color: 'success' })
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const topStats = computed(() => {
|
|
319
|
-
const s = stats.value as any
|
|
320
|
-
if (!s) return []
|
|
321
|
-
const totalT = (s.languages as any[]).reduce((sum: number, l: any) => sum + l.translated, 0)
|
|
322
|
-
const totalP = (s.languages as any[]).reduce((sum: number, l: any) => sum + l.total, 0)
|
|
323
|
-
const cov = totalP > 0 ? Math.round((totalT / totalP) * 100) : 0
|
|
324
|
-
return [
|
|
325
|
-
{ label: t('dashboard.total_keys', 'total keys'), value: s.totalKeys, icon: 'i-heroicons-key', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
|
326
|
-
{ label: t('dashboard.languages', 'Languages'), value: s.languages.length, icon: 'i-heroicons-flag', color: 'text-purple-600', bg: 'bg-purple-50 dark:bg-purple-900/20' },
|
|
327
|
-
{ label: t('dashboard.coverage', 'coverage'), value: `${cov}%`, icon: 'i-heroicons-check-badge', color: 'text-green-600', bg: 'bg-green-50 dark:bg-green-900/20' },
|
|
328
|
-
{ label: t('dashboard.unused_keys', 'unused keys'), value: s.unusedKeys, icon: 'i-heroicons-exclamation-triangle', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-900/20' },
|
|
329
|
-
]
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
function pct(v: number, t: number) { return t > 0 ? Math.min(100, Math.round((v / t) * 100)) : 0 }
|
|
333
|
-
|
|
334
|
-
function formatRelative(date: string) {
|
|
335
|
-
const diff = Date.now() - new Date(date).getTime()
|
|
336
|
-
const min = Math.floor(diff / 60000)
|
|
337
|
-
if (min < 1) return t('common.just_now', 'just now')
|
|
338
|
-
if (min < 60) return `${t('common.ago', 'ago')} ${min}min`
|
|
339
|
-
const h = Math.floor(min / 60)
|
|
340
|
-
if (h < 24) return `${t('common.ago', 'ago')} ${h}h`
|
|
341
|
-
return `${t('common.ago', 'ago')} ${Math.floor(h / 24)}d`
|
|
342
|
-
}
|
|
9
|
+
const projectId = computed(() => Number(route.params.id))
|
|
343
10
|
</script>
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
:data-cy="'lang-coverage-' + lang.code"
|
|
96
96
|
class="font-medium text-gray-700 dark:text-gray-300"
|
|
97
97
|
>
|
|
98
|
-
{{ getCoverage(lang.code) }}%
|
|
98
|
+
{{ getCoverage(lang.code).toFixed(2) }}%
|
|
99
99
|
</span>
|
|
100
100
|
</div>
|
|
101
101
|
<div class="mt-2 h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
|
@@ -109,6 +109,25 @@
|
|
|
109
109
|
{{ getTranslatedCount(lang.code) }} / {{ totalKeys }} {{ t('languages.keys_translated', 'keys translated') }}
|
|
110
110
|
</p>
|
|
111
111
|
|
|
112
|
+
<!-- Translate missing shortcut -->
|
|
113
|
+
<div
|
|
114
|
+
v-if="!lang.is_default && getMissingCount(lang.code) > 0"
|
|
115
|
+
class="mt-2"
|
|
116
|
+
>
|
|
117
|
+
<UButton
|
|
118
|
+
size="xs"
|
|
119
|
+
variant="ghost"
|
|
120
|
+
color="warning"
|
|
121
|
+
icon="i-heroicons-sparkles"
|
|
122
|
+
:loading="translatingLang === lang.code"
|
|
123
|
+
:disabled="showProgress || (translatingLang !== null && translatingLang !== lang.code)"
|
|
124
|
+
@click="translateMissing(lang)"
|
|
125
|
+
>
|
|
126
|
+
{{ t('languages.translate_missing', 'Translate missing') }}
|
|
127
|
+
<span class="opacity-60">({{ getMissingCount(lang.code) }})</span>
|
|
128
|
+
</UButton>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
112
131
|
<!-- Fallback indicator -->
|
|
113
132
|
<div class="mt-3 pt-2 border-t border-gray-100 dark:border-gray-800">
|
|
114
133
|
<button
|
|
@@ -398,7 +417,7 @@
|
|
|
398
417
|
{{ findLanguage(l.code)?.nativeName || l.name }}
|
|
399
418
|
<code class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded ml-1">{{ l.code }}</code>
|
|
400
419
|
</p>
|
|
401
|
-
<p class="text-xs text-gray-400">{{ getCoverage(l.code) }}% {{ t('languages.translated', 'translated') }}</p>
|
|
420
|
+
<p class="text-xs text-gray-400">{{ getCoverage(l.code).toFixed(2) }}% {{ t('languages.translated', 'translated') }}</p>
|
|
402
421
|
</div>
|
|
403
422
|
<UBadge
|
|
404
423
|
v-if="l.is_default"
|
|
@@ -725,6 +744,24 @@ function sendToBackground() {
|
|
|
725
744
|
})
|
|
726
745
|
}
|
|
727
746
|
|
|
747
|
+
const translatingLang = ref<string | null>(null)
|
|
748
|
+
|
|
749
|
+
function getMissingCount(code: string): number {
|
|
750
|
+
return Math.max(0, totalKeys.value - getTranslatedCount(code))
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function translateMissing(lang: any) {
|
|
754
|
+
if (translatingLang.value) return
|
|
755
|
+
translatingLang.value = lang.code
|
|
756
|
+
try {
|
|
757
|
+
const langName = findLanguage(lang.code)?.nativeName || lang.name
|
|
758
|
+
const jobId = await startTranslateAll(lang.code, lang.name)
|
|
759
|
+
if (jobId) startPolling(jobId, langName)
|
|
760
|
+
} finally {
|
|
761
|
+
translatingLang.value = null
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
728
765
|
// ── Add language ─────────────────────────────────────────────────────────
|
|
729
766
|
async function addLanguage() {
|
|
730
767
|
if (!newLang.value.code || !newLang.value.name || !currentProject.value) return
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { requireAuth } from '../../utils/auth.util'
|
|
2
|
+
import { getDb } from '../../db/index'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const user = await requireAuth(event)
|
|
6
|
+
const query = getQuery(event)
|
|
7
|
+
const projectId = Number(query.project_id)
|
|
8
|
+
|
|
9
|
+
if (!projectId) throw createError({ statusCode: 400, message: 'project_id is required' })
|
|
10
|
+
|
|
11
|
+
const db = getDb()
|
|
12
|
+
const settingKey = `dashboard_project_layout_${user.id}_${projectId}`
|
|
13
|
+
const row = await db('settings').where({ key: settingKey }).first()
|
|
14
|
+
|
|
15
|
+
if (!row || !row.value) return null
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(row.value)
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { requireAuth } from '../../utils/auth.util'
|
|
2
|
+
import { getDb } from '../../db/index'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const user = await requireAuth(event)
|
|
6
|
+
const body = await readBody(event)
|
|
7
|
+
const { project_id, widgets } = body
|
|
8
|
+
|
|
9
|
+
if (!project_id) throw createError({ statusCode: 400, message: 'project_id is required' })
|
|
10
|
+
|
|
11
|
+
const db = getDb()
|
|
12
|
+
const settingKey = `dashboard_project_layout_${user.id}_${project_id}`
|
|
13
|
+
const value = JSON.stringify(widgets)
|
|
14
|
+
|
|
15
|
+
await db('settings')
|
|
16
|
+
.insert({ key: settingKey, value, updated_at: db.fn.now() })
|
|
17
|
+
.onConflict('key')
|
|
18
|
+
.merge()
|
|
19
|
+
|
|
20
|
+
return { ok: true }
|
|
21
|
+
})
|
|
@@ -105,7 +105,7 @@ export default defineEventHandler(async (event) => {
|
|
|
105
105
|
const langStats = Array.from(langMap.values()).map(l => ({
|
|
106
106
|
...l,
|
|
107
107
|
missing: l.total - l.translated,
|
|
108
|
-
coverage: l.total > 0 ?
|
|
108
|
+
coverage: l.total > 0 ? parseFloat(((l.translated / l.total) * 100).toFixed(2)) : 0,
|
|
109
109
|
}))
|
|
110
110
|
|
|
111
111
|
// Recent activity across all accessible projects
|
|
@@ -48,7 +48,7 @@ export default defineEventHandler(async (event) => {
|
|
|
48
48
|
draft: statusMap.draft,
|
|
49
49
|
reviewed: statusMap.reviewed,
|
|
50
50
|
approved: statusMap.approved,
|
|
51
|
-
coverage: total > 0 ?
|
|
51
|
+
coverage: total > 0 ? parseFloat(((translatedCount / total) * 100).toFixed(2)) : 0,
|
|
52
52
|
}
|
|
53
53
|
}),
|
|
54
54
|
)
|