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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-dashboard",
3
- "version": "0.15.4",
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": "^6.10.1",
33
+ "nodemailer": "^8.0.3",
34
34
  "nuxt": "^3.21.1",
35
35
  "vue": "^3.5.13"
36
36
  },
@@ -75,20 +75,23 @@
75
75
  "localization"
76
76
  ],
77
77
  "license": "MIT",
78
+ "overrides": {
79
+ "flatted": "^3.4.2"
80
+ },
78
81
  "devDependencies": {
79
82
  "@iconify-json/heroicons": "^1.2.3",
80
83
  "@types/node": "^25.3.5",
81
84
  "@typescript-eslint/eslint-plugin": "^8.57.1",
82
85
  "@typescript-eslint/parser": "^8.57.1",
83
- "@vitest/coverage-v8": "^4.1.0",
84
- "@vitest/ui": "^4.1.0",
86
+ "@vitest/coverage-v8": "^4.1.1",
87
+ "@vitest/ui": "^4.1.1",
85
88
  "@vue/eslint-config-typescript": "^14.7.0",
86
89
  "@vue/test-utils": "^2.4.6",
87
90
  "eslint": "^10.0.3",
88
91
  "eslint-plugin-vue": "^10.8.0",
89
92
  "happy-dom": "^20.8.4",
90
93
  "husky": "^9.1.7",
91
- "vitest": "^4.1.0",
94
+ "vitest": "^4.1.1",
92
95
  "vue-eslint-parser": "^10.4.0"
93
96
  }
94
97
  }
@@ -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="(config, type) in WIDGET_REGISTRY"
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 Math.round(total / langs.length)
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 space-y-6">
3
- <div class="flex items-center justify-between">
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 ? Math.round((l.translated / l.total) * 100) : 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 ? Math.round((translatedCount / total) * 100) : 0,
51
+ coverage: total > 0 ? parseFloat(((translatedCount / total) * 100).toFixed(2)) : 0,
52
52
  }
53
53
  }),
54
54
  )