i18n-dashboard 0.1.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.
Files changed (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +715 -0
  3. package/app.vue +8 -0
  4. package/assets/css/main.css +21 -0
  5. package/assets/locales/en.json +380 -0
  6. package/bin/cli.mjs +279 -0
  7. package/components/LinkedKeyPicker.vue +135 -0
  8. package/components/PathPicker.vue +153 -0
  9. package/components/PluralEditor.vue +295 -0
  10. package/components/ScanModal.vue +153 -0
  11. package/components/TranslationHistoryModal.vue +66 -0
  12. package/components/TranslationRow.vue +541 -0
  13. package/components/dashboard/WidgetConfigModal.vue +121 -0
  14. package/components/dashboard/WidgetGrid.vue +190 -0
  15. package/components/dashboard/WidgetPicker.vue +75 -0
  16. package/components/dashboard/widgets/ActivityWidget.vue +109 -0
  17. package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
  18. package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
  19. package/components/dashboard/widgets/ReviewWidget.vue +150 -0
  20. package/components/dashboard/widgets/StatWidget.vue +133 -0
  21. package/composables/useAuth.ts +72 -0
  22. package/composables/useConfig.ts +14 -0
  23. package/composables/useDashboard.ts +89 -0
  24. package/composables/useFormats.ts +100 -0
  25. package/composables/useKeys.ts +231 -0
  26. package/composables/useLanguages.ts +221 -0
  27. package/composables/useProfile.ts +76 -0
  28. package/composables/useProject.ts +180 -0
  29. package/composables/useReview.ts +94 -0
  30. package/composables/useSettings.ts +30 -0
  31. package/composables/useStats.ts +16 -0
  32. package/composables/useT.ts +38 -0
  33. package/composables/useUsers.ts +101 -0
  34. package/composables/useWidgetData.ts +50 -0
  35. package/consts/commons.const.ts +6 -0
  36. package/consts/dashboard.const.ts +94 -0
  37. package/consts/languages.const.ts +223 -0
  38. package/enums/commons.enum.ts +7 -0
  39. package/i18n-dashboard.config.example.js +40 -0
  40. package/interfaces/commons.interface.ts +23 -0
  41. package/interfaces/job.interface.ts +10 -0
  42. package/interfaces/key.interface.ts +39 -0
  43. package/interfaces/languages.interface.ts +23 -0
  44. package/interfaces/project.interface.ts +9 -0
  45. package/interfaces/scan.interface.ts +12 -0
  46. package/interfaces/settings.interface.ts +4 -0
  47. package/interfaces/stat.interface.ts +30 -0
  48. package/interfaces/translation.interface.ts +11 -0
  49. package/interfaces/user.interface.ts +24 -0
  50. package/layouts/auth.vue +5 -0
  51. package/layouts/default.vue +327 -0
  52. package/middleware/auth.global.ts +26 -0
  53. package/nuxt.config.ts +66 -0
  54. package/package.json +89 -0
  55. package/pages/index.vue +5 -0
  56. package/pages/login.vue +74 -0
  57. package/pages/onboarding.vue +563 -0
  58. package/pages/projects/[id]/formats/datetime.vue +240 -0
  59. package/pages/projects/[id]/formats/modifiers.vue +194 -0
  60. package/pages/projects/[id]/formats/number.vue +250 -0
  61. package/pages/projects/[id]/index.vue +182 -0
  62. package/pages/projects/[id]/languages.vue +537 -0
  63. package/pages/projects/[id]/review.vue +109 -0
  64. package/pages/projects/[id]/settings.vue +515 -0
  65. package/pages/projects/[id]/translations/[keyId].vue +642 -0
  66. package/pages/projects/[id]/translations/index.vue +250 -0
  67. package/pages/projects/[id]/users.vue +276 -0
  68. package/pages/projects/index.vue +334 -0
  69. package/pages/users/[id]/profile.vue +421 -0
  70. package/pages/users/index.vue +345 -0
  71. package/plugins/loading.client.ts +3 -0
  72. package/plugins/ui-i18n.ts +6 -0
  73. package/server/api/auth/login.post.ts +28 -0
  74. package/server/api/auth/logout.post.ts +7 -0
  75. package/server/api/auth/me.get.ts +11 -0
  76. package/server/api/auth/me.put.ts +31 -0
  77. package/server/api/auth/password.put.ts +27 -0
  78. package/server/api/auth/status.get.ts +16 -0
  79. package/server/api/config.get.ts +10 -0
  80. package/server/api/dashboard/layout.get.ts +18 -0
  81. package/server/api/dashboard/layout.post.ts +18 -0
  82. package/server/api/db-config.get.ts +44 -0
  83. package/server/api/db-config.post.ts +73 -0
  84. package/server/api/export.get.ts +64 -0
  85. package/server/api/formats/datetime/[id].delete.ts +8 -0
  86. package/server/api/formats/datetime/[id].put.ts +15 -0
  87. package/server/api/formats/datetime.get.ts +11 -0
  88. package/server/api/formats/datetime.post.ts +16 -0
  89. package/server/api/formats/modifiers/[id].delete.ts +8 -0
  90. package/server/api/formats/modifiers/[id].put.ts +10 -0
  91. package/server/api/formats/modifiers.get.ts +10 -0
  92. package/server/api/formats/modifiers.post.ts +14 -0
  93. package/server/api/formats/number/[id].delete.ts +8 -0
  94. package/server/api/formats/number/[id].put.ts +15 -0
  95. package/server/api/formats/number.get.ts +11 -0
  96. package/server/api/formats/number.post.ts +16 -0
  97. package/server/api/formats/snippet.get.ts +87 -0
  98. package/server/api/fs/browse.get.ts +50 -0
  99. package/server/api/history/[translationId].get.ts +13 -0
  100. package/server/api/keys/[id].delete.ts +14 -0
  101. package/server/api/keys/[id].get.ts +41 -0
  102. package/server/api/keys/[id].patch.ts +20 -0
  103. package/server/api/keys/index.get.ts +98 -0
  104. package/server/api/keys/index.post.ts +17 -0
  105. package/server/api/languages/[code].delete.ts +15 -0
  106. package/server/api/languages/[id].put.ts +24 -0
  107. package/server/api/languages/index.get.ts +13 -0
  108. package/server/api/languages/index.post.ts +42 -0
  109. package/server/api/onboarding.post.ts +56 -0
  110. package/server/api/profile.get.ts +81 -0
  111. package/server/api/project-snapshot.get.ts +73 -0
  112. package/server/api/project-snapshot.post.ts +160 -0
  113. package/server/api/projects/[id].delete.ts +13 -0
  114. package/server/api/projects/[id].put.ts +40 -0
  115. package/server/api/projects/index.get.ts +19 -0
  116. package/server/api/projects/index.post.ts +34 -0
  117. package/server/api/scan.post.ts +165 -0
  118. package/server/api/settings/index.get.ts +9 -0
  119. package/server/api/settings/index.post.ts +20 -0
  120. package/server/api/setup.post.ts +39 -0
  121. package/server/api/stats/global.get.ts +126 -0
  122. package/server/api/stats.get.ts +70 -0
  123. package/server/api/sync.post.ts +179 -0
  124. package/server/api/translate.post.ts +52 -0
  125. package/server/api/translations/batch-translate.post.ts +121 -0
  126. package/server/api/translations/bulk-status.post.ts +24 -0
  127. package/server/api/translations/index.post.ts +62 -0
  128. package/server/api/translations/job/[id].get.ts +23 -0
  129. package/server/api/translations/status.post.ts +30 -0
  130. package/server/api/translations/translate-all.post.ts +18 -0
  131. package/server/api/ui-locale.get.ts +39 -0
  132. package/server/api/users/[id]/profile.get.ts +107 -0
  133. package/server/api/users/[id]/roles.put.ts +67 -0
  134. package/server/api/users/[id].delete.ts +36 -0
  135. package/server/api/users/[id].put.ts +43 -0
  136. package/server/api/users/index.get.ts +49 -0
  137. package/server/api/users/index.post.ts +89 -0
  138. package/server/consts/auto-translate.const.ts +2 -0
  139. package/server/consts/commons.const.ts +10 -0
  140. package/server/consts/db.const.ts +3 -0
  141. package/server/consts/scanner.const.ts +4 -0
  142. package/server/consts/translation-job.const.ts +8 -0
  143. package/server/db/index.ts +672 -0
  144. package/server/enums/auth.enum.ts +5 -0
  145. package/server/enums/translation.enum.ts +6 -0
  146. package/server/interfaces/profile.interface.ts +48 -0
  147. package/server/interfaces/project-config.interface.ts +9 -0
  148. package/server/interfaces/scanner.interface.ts +18 -0
  149. package/server/interfaces/translation-job.interface.ts +13 -0
  150. package/server/middleware/auth.ts +32 -0
  151. package/server/plugins/db.ts +6 -0
  152. package/server/routes/locale/[lang].get.ts +179 -0
  153. package/server/types/auth.type.ts +3 -0
  154. package/server/utils/auth.util.ts +89 -0
  155. package/server/utils/auto-translate.util.ts +112 -0
  156. package/server/utils/lang-api.util.ts +24 -0
  157. package/server/utils/mailer.util.ts +80 -0
  158. package/server/utils/project-config.util.ts +37 -0
  159. package/server/utils/scanner.uti.ts +307 -0
  160. package/server/utils/translation-job.util.ts +142 -0
  161. package/services/auth.service.ts +31 -0
  162. package/services/base.service.ts +140 -0
  163. package/services/job.service.ts +10 -0
  164. package/services/key.service.ts +26 -0
  165. package/services/language.service.ts +26 -0
  166. package/services/profile.service.ts +14 -0
  167. package/services/project.service.ts +23 -0
  168. package/services/scan.service.ts +14 -0
  169. package/services/settings.service.ts +14 -0
  170. package/services/stats.service.ts +11 -0
  171. package/services/translation.service.ts +36 -0
  172. package/services/user.service.ts +28 -0
  173. package/tsconfig.json +3 -0
  174. package/types/commons.type.ts +3 -0
  175. package/types/dashboard.type.ts +26 -0
  176. package/utils/config.util.ts +60 -0
@@ -0,0 +1,190 @@
1
+ <script lang="ts" setup>
2
+ import type { WidgetType } from '~/types/dashboard.type'
3
+ import { WIDGET_REGISTRY, WIDGET_SIZE_CLASSES } from '~/consts/dashboard.const'
4
+
5
+ const { t } = useT()
6
+ const { currentUser } = useAuth()
7
+ const {
8
+ layout,
9
+ editing,
10
+ localLayout,
11
+ saving,
12
+ startEditing,
13
+ cancelEditing,
14
+ saveLayout,
15
+ onDragStart,
16
+ onDragOver,
17
+ onDragEnd,
18
+ removeWidget,
19
+ addWidget,
20
+ resizeWidget,
21
+ updateWidgetConfig,
22
+ } = useDashboard()
23
+
24
+ const showPicker = ref(false)
25
+ const configIndex = ref(-1)
26
+
27
+ const activeLayout = computed(() => editing.value ? localLayout.value : layout.value)
28
+
29
+ function sizeClass(size: string) {
30
+ return WIDGET_SIZE_CLASSES[size as keyof typeof WIDGET_SIZE_CLASSES] ?? 'col-span-1 row-span-1'
31
+ }
32
+
33
+ function widgetComponent(type: WidgetType) {
34
+ switch (type) {
35
+ case 'stat-keys':
36
+ case 'stat-coverage':
37
+ case 'stat-languages':
38
+ case 'stat-unused':
39
+ return resolveComponent('DashboardWidgetsStatWidget')
40
+ case 'projects':
41
+ return resolveComponent('DashboardWidgetsProjectsWidget')
42
+ case 'languages-coverage':
43
+ return resolveComponent('DashboardWidgetsLanguagesCoverageWidget')
44
+ case 'last-activity':
45
+ return resolveComponent('DashboardWidgetsActivityWidget')
46
+ case 'review-queue':
47
+ return resolveComponent('DashboardWidgetsReviewWidget')
48
+ default:
49
+ return 'div'
50
+ }
51
+ }
52
+
53
+ function onAddWidget(widget: Parameters<typeof addWidget>[0]) {
54
+ addWidget(widget)
55
+ }
56
+
57
+ function onSaveConfig(patch: { dataSource?: any; title?: string | undefined }) {
58
+ updateWidgetConfig(configIndex.value, patch)
59
+ configIndex.value = -1
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <div>
65
+ <div class="flex items-center justify-between mb-6">
66
+ <div>
67
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
68
+ {{ t('dashboard.hello', 'Hello') }}{{ currentUser?.name ? `, ${currentUser.name}` : '' }}
69
+ </h1>
70
+ <p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">{{ t('dashboard.title', 'Dashboard') }}</p>
71
+ </div>
72
+
73
+ <div class="flex items-center gap-2">
74
+ <UButton
75
+ v-if="!editing"
76
+ variant="ghost"
77
+ color="neutral"
78
+ icon="i-heroicons-pencil-square"
79
+ @click="startEditing"
80
+ >
81
+ {{ t('dashboard.edit', 'Edit') }}
82
+ </UButton>
83
+ <template v-else>
84
+ <UButton variant="ghost" color="neutral" @click="cancelEditing">
85
+ {{ t('common.cancel', 'Cancel') }}
86
+ </UButton>
87
+ <UButton
88
+ icon="i-heroicons-plus"
89
+ variant="outline"
90
+ color="neutral"
91
+ @click="showPicker = true"
92
+ >
93
+ {{ t('common.add', 'Add') }}
94
+ </UButton>
95
+ <UButton :loading="saving" icon="i-heroicons-check" @click="saveLayout">
96
+ {{ t('dashboard.done', 'Done') }}
97
+ </UButton>
98
+ </template>
99
+ </div>
100
+ </div>
101
+
102
+ <div
103
+ v-if="activeLayout.length === 0"
104
+ class="border border-dashed border-gray-300 dark:border-gray-700 rounded-xl p-16 text-center"
105
+ >
106
+ <UIcon name="i-heroicons-squares-2x2" class="text-5xl text-gray-300 dark:text-gray-600 mb-3" />
107
+ <p class="text-gray-400 font-medium">{{ t('dashboard.no_widgets', 'No widgets') }}</p>
108
+ <UButton v-if="editing" class="mt-4" icon="i-heroicons-plus" @click="showPicker = true">
109
+ {{ t('dashboard.add_widget', 'Add a widget') }}
110
+ </UButton>
111
+ </div>
112
+
113
+ <div
114
+ v-else
115
+ class="grid grid-cols-4 gap-4 auto-rows-[minmax(140px,auto)]"
116
+ >
117
+ <div
118
+ v-for="(widget, index) in activeLayout"
119
+ :key="widget.id"
120
+ :class="[sizeClass(widget.size), 'relative', editing ? 'cursor-grab' : '']"
121
+ :draggable="editing"
122
+ @dragstart="onDragStart(index)"
123
+ @dragover="onDragOver($event, index)"
124
+ @dragend="onDragEnd"
125
+ >
126
+ <button
127
+ v-if="editing"
128
+ 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"
129
+ @click.stop="removeWidget(index)"
130
+ >
131
+ ×
132
+ </button>
133
+
134
+ <div v-if="editing" class="absolute -top-2 right-0 z-10 flex gap-0.5">
135
+ <button
136
+ v-for="s in WIDGET_REGISTRY[widget.type].sizes"
137
+ :key="s"
138
+ class="px-1 py-0.5 text-xs rounded shadow-sm transition-colors"
139
+ :class="widget.size === s
140
+ ? 'bg-primary-500 text-white'
141
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'"
142
+ @click.stop="resizeWidget(index, s)"
143
+ >
144
+ {{ s }}
145
+ </button>
146
+ </div>
147
+
148
+ <button
149
+ v-if="editing && WIDGET_REGISTRY[widget.type].hasDataSource"
150
+ 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"
151
+ @click.stop="configIndex = index"
152
+ >
153
+ <UIcon name="i-heroicons-cog-6-tooth" class="text-xs" />
154
+ </button>
155
+
156
+ <div :class="editing ? 'animate-wiggle h-full' : 'h-full'">
157
+ <component
158
+ :is="widgetComponent(widget.type)"
159
+ :id="widget.id"
160
+ :type="widget.type"
161
+ :size="widget.size"
162
+ :editing="editing"
163
+ :data-source="widget.dataSource"
164
+ :title="widget.title"
165
+ />
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <DashboardWidgetPicker v-model="showPicker" @add="onAddWidget" />
171
+
172
+ <DashboardWidgetConfigModal
173
+ :open="configIndex !== -1"
174
+ :widget="configIndex !== -1 ? activeLayout[configIndex] : null"
175
+ :index="configIndex"
176
+ @update:open="val => { if (!val) configIndex = -1 }"
177
+ @save="onSaveConfig"
178
+ />
179
+ </div>
180
+ </template>
181
+
182
+ <style scoped>
183
+ @keyframes wiggle {
184
+ 0%, 100% { transform: rotate(-0.5deg); }
185
+ 50% { transform: rotate(0.5deg); }
186
+ }
187
+ .animate-wiggle {
188
+ animation: wiggle 0.4s ease-in-out infinite;
189
+ }
190
+ </style>
@@ -0,0 +1,75 @@
1
+ <script lang="ts" setup>
2
+ import type { WidgetConfig, WidgetSize } from '~/types/dashboard.type'
3
+ import { WIDGET_REGISTRY } from '~/consts/dashboard.const'
4
+
5
+ const { t } = useT()
6
+
7
+ const props = defineProps<{
8
+ modelValue: boolean
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ 'update:modelValue': [value: boolean]
13
+ 'add': [widget: WidgetConfig]
14
+ }>()
15
+
16
+ const open = computed({
17
+ get: () => props.modelValue,
18
+ set: (v) => emit('update:modelValue', v),
19
+ })
20
+
21
+ const selectedSizes = ref<Record<string, WidgetSize>>({})
22
+
23
+ function getSelectedSize(type: string): WidgetSize {
24
+ return selectedSizes.value[type] ?? WIDGET_REGISTRY[type as keyof typeof WIDGET_REGISTRY].defaultSize
25
+ }
26
+
27
+ function addWidget(type: string) {
28
+ const size = getSelectedSize(type)
29
+ const id = Date.now().toString(36)
30
+ emit('add', { id, type: type as WidgetConfig['type'], size })
31
+ open.value = false
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <UModal v-model:open="open" :title="t('dashboard.add_widget', 'Add a widget')" :ui="{ width: 'max-w-2xl' }">
37
+ <template #body>
38
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-1">
39
+ <div
40
+ v-for="(config, type) in WIDGET_REGISTRY"
41
+ :key="type"
42
+ 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"
43
+ >
44
+ <div class="flex items-start gap-3">
45
+ <div class="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center shrink-0">
46
+ <UIcon :name="config.icon" class="text-gray-600 dark:text-gray-400" />
47
+ </div>
48
+ <div class="min-w-0">
49
+ <p class="text-sm font-semibold text-gray-900 dark:text-white">{{ config.label }}</p>
50
+ <p class="text-xs text-gray-400 mt-0.5">{{ config.description }}</p>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="flex items-center gap-1.5 flex-wrap">
55
+ <button
56
+ v-for="s in config.sizes"
57
+ :key="s"
58
+ class="px-2 py-0.5 text-xs rounded-md border transition-colors"
59
+ :class="getSelectedSize(type) === s
60
+ ? 'bg-primary-500 border-primary-500 text-white'
61
+ : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-primary-400'"
62
+ @click="selectedSizes[type] = s"
63
+ >
64
+ {{ s }}
65
+ </button>
66
+ </div>
67
+
68
+ <UButton size="xs" variant="soft" class="w-full justify-center" @click="addWidget(type)">
69
+ {{ t('common.add', 'Add') }}
70
+ </UButton>
71
+ </div>
72
+ </div>
73
+ </template>
74
+ </UModal>
75
+ </template>
@@ -0,0 +1,109 @@
1
+ <script lang="ts" setup>
2
+ import type { PropType } from 'vue'
3
+ import type { WidgetSize, WidgetDataSource } from '~/types/dashboard.type'
4
+
5
+ const props = defineProps({
6
+ id: {
7
+ type: String,
8
+ required: true,
9
+ },
10
+ size: {
11
+ type: String as PropType<WidgetSize>,
12
+ required: true,
13
+ },
14
+ editing: {
15
+ type: Boolean,
16
+ required: true,
17
+ },
18
+ dataSource: {
19
+ type: Object as PropType<WidgetDataSource | undefined>,
20
+ default: undefined,
21
+ },
22
+ title: {
23
+ type: String as PropType<string | undefined>,
24
+ default: undefined,
25
+ },
26
+ })
27
+
28
+ const { t } = useT()
29
+
30
+ const { stats, pending, sourceLabel, hasProject } = useWidgetData(
31
+ props.id,
32
+ computed(() => props.dataSource),
33
+ )
34
+
35
+ const maxItems = computed(() => {
36
+ if (props.size === 'lg') return 10
37
+ if (props.size === 'wide') return 8
38
+ return 5
39
+ })
40
+
41
+ const displayedActivity = computed(() => (stats.value?.recentActivity ?? []).slice(0, maxItems.value))
42
+
43
+ const displayTitle = computed(() => props.title || t('dashboard.recent_activity', 'Recent activity'))
44
+
45
+ function formatRelative(date: string) {
46
+ const diff = Date.now() - new Date(date).getTime()
47
+ const min = Math.floor(diff / 60000)
48
+ if (min < 1) return t('common.just_now', 'just now')
49
+ if (min < 60) return `${min}min ${t('common.ago', 'ago')}`
50
+ const h = Math.floor(min / 60)
51
+ if (h < 24) return `${h}h ${t('common.ago', 'ago')}`
52
+ return `${Math.floor(h / 24)}d ${t('common.ago', 'ago')}`
53
+ }
54
+
55
+ function activityIcon(changedBy: string) {
56
+ if (changedBy === 'google-translate') return 'i-heroicons-sparkles'
57
+ if (changedBy === 'sync') return 'i-heroicons-arrow-path'
58
+ return 'i-heroicons-pencil'
59
+ }
60
+ </script>
61
+
62
+ <template>
63
+ <UCard class="h-full overflow-hidden">
64
+ <template #header>
65
+ <div class="flex items-center gap-2">
66
+ <UIcon name="i-heroicons-clock" class="text-gray-400" />
67
+ <span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ displayTitle }}</span>
68
+ <span v-if="sourceLabel" class="ml-auto text-xs text-gray-400 dark:text-gray-500">{{ sourceLabel }}</span>
69
+ </div>
70
+ </template>
71
+
72
+ <div v-if="pending" class="space-y-2">
73
+ <USkeleton v-for="i in 4" :key="i" class="h-8 w-full" />
74
+ </div>
75
+
76
+ <div v-else-if="!hasProject" class="flex flex-col items-center justify-center h-full py-6 text-center">
77
+ <UIcon name="i-heroicons-clock" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
78
+ <p class="text-sm text-gray-400">{{ t('dashboard.select_project', 'Select a project') }}</p>
79
+ </div>
80
+
81
+ <div v-else-if="!displayedActivity.length" class="flex flex-col items-center justify-center h-full py-6 text-center">
82
+ <UIcon name="i-heroicons-clock" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
83
+ <p class="text-sm text-gray-400">{{ t('dashboard.no_activity', 'No recent activity') }}</p>
84
+ </div>
85
+
86
+ <div
87
+ v-else
88
+ class="overflow-y-auto"
89
+ :class="size === 'wide' ? 'grid grid-cols-2 gap-x-4 gap-y-2' : 'space-y-2'"
90
+ >
91
+ <div
92
+ v-for="item in displayedActivity"
93
+ :key="item.id"
94
+ class="flex items-start gap-2 py-1"
95
+ >
96
+ <div class="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center shrink-0 mt-0.5">
97
+ <UIcon :name="activityIcon(item.changed_by)" class="text-xs text-gray-500 dark:text-gray-400" />
98
+ </div>
99
+ <div class="min-w-0 flex-1">
100
+ <p class="text-xs font-mono text-gray-700 dark:text-gray-300 truncate">{{ item.key }}</p>
101
+ <div class="flex items-center gap-1.5 mt-0.5">
102
+ <UBadge :label="item.language_code.toUpperCase()" color="neutral" variant="soft" size="xs" />
103
+ <span class="text-xs text-gray-400">{{ formatRelative(item.changed_at) }}</span>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </UCard>
109
+ </template>
@@ -0,0 +1,104 @@
1
+ <script lang="ts" setup>
2
+ import type { PropType } from 'vue'
3
+ import type { WidgetSize, WidgetDataSource } from '~/types/dashboard.type'
4
+
5
+ const props = defineProps({
6
+ id: {
7
+ type: String,
8
+ required: true,
9
+ },
10
+ size: {
11
+ type: String as PropType<WidgetSize>,
12
+ required: true,
13
+ },
14
+ editing: {
15
+ type: Boolean,
16
+ required: true,
17
+ },
18
+ dataSource: {
19
+ type: Object as PropType<WidgetDataSource | undefined>,
20
+ default: undefined,
21
+ },
22
+ title: {
23
+ type: String as PropType<string | undefined>,
24
+ default: undefined,
25
+ },
26
+ })
27
+
28
+ const { t } = useT()
29
+
30
+ const { stats, pending, sourceLabel, hasProject } = useWidgetData(
31
+ props.id,
32
+ computed(() => props.dataSource),
33
+ )
34
+
35
+ const maxItems = computed(() => {
36
+ if (props.size === 'lg') return 6
37
+ if (props.size === 'wide') return 8
38
+ return 3
39
+ })
40
+
41
+ const displayedLanguages = computed(() => (stats.value?.languages ?? []).slice(0, maxItems.value))
42
+
43
+ const displayTitle = computed(() => props.title || t('dashboard.languages_coverage', 'Language coverage'))
44
+
45
+ function coverageColor(coverage: number) {
46
+ if (coverage >= 90) return 'bg-green-500'
47
+ if (coverage >= 60) return 'bg-yellow-400'
48
+ return 'bg-red-400'
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <UCard class="h-full overflow-hidden">
54
+ <template #header>
55
+ <div class="flex items-center gap-2">
56
+ <UIcon name="i-heroicons-globe-alt" class="text-gray-400" />
57
+ <span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ displayTitle }}</span>
58
+ <span v-if="sourceLabel" class="ml-auto text-xs text-gray-400 dark:text-gray-500">{{ sourceLabel }}</span>
59
+ </div>
60
+ </template>
61
+
62
+ <div v-if="pending" class="space-y-3">
63
+ <USkeleton v-for="i in 3" :key="i" class="h-8 w-full" />
64
+ </div>
65
+
66
+ <div v-else-if="!hasProject" class="flex flex-col items-center justify-center h-full py-6 text-center">
67
+ <UIcon name="i-heroicons-globe-alt" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
68
+ <p class="text-sm text-gray-400">{{ t('dashboard.select_project', 'Select a project') }}</p>
69
+ </div>
70
+
71
+ <div v-else-if="!displayedLanguages.length" class="flex flex-col items-center justify-center h-full py-6 text-center">
72
+ <UIcon name="i-heroicons-globe-alt" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
73
+ <p class="text-sm text-gray-400">{{ t('dashboard.no_languages', 'No language configured') }}</p>
74
+ </div>
75
+
76
+ <div
77
+ v-else
78
+ class="overflow-y-auto space-y-3"
79
+ :class="size === 'wide' ? 'grid grid-cols-2 gap-3 space-y-0' : ''"
80
+ >
81
+ <div v-for="lang in displayedLanguages" :key="lang.code" class="space-y-1">
82
+ <div class="flex items-center justify-between text-xs">
83
+ <span class="font-medium text-gray-700 dark:text-gray-300">
84
+ {{ lang.name }}
85
+ <span class="text-gray-400 ml-1 font-mono">{{ lang.code }}</span>
86
+ </span>
87
+ <span class="font-semibold" :class="lang.coverage >= 90 ? 'text-green-600' : lang.coverage >= 60 ? 'text-yellow-500' : 'text-red-500'">
88
+ {{ lang.coverage }}%
89
+ </span>
90
+ </div>
91
+ <div class="w-full bg-gray-100 dark:bg-gray-800 rounded-full h-1.5">
92
+ <div
93
+ class="h-1.5 rounded-full transition-all"
94
+ :class="coverageColor(lang.coverage)"
95
+ :style="{ width: `${lang.coverage}%` }"
96
+ />
97
+ </div>
98
+ <p v-if="size !== 'sm'" class="text-xs text-gray-400">
99
+ {{ lang.translated }} / {{ lang.total }} · {{ lang.missing }} {{ t('dashboard.missing', 'missing') }}
100
+ </p>
101
+ </div>
102
+ </div>
103
+ </UCard>
104
+ </template>
@@ -0,0 +1,77 @@
1
+ <script lang="ts" setup>
2
+ import type { WidgetSize } from '~/types/dashboard.type'
3
+
4
+ const props = defineProps<{
5
+ size: WidgetSize
6
+ editing: boolean
7
+ }>()
8
+
9
+ const { t } = useT()
10
+ const { visibleProjects, pending } = useProject()
11
+ const router = useRouter()
12
+
13
+ const maxItems = computed(() => {
14
+ if (props.size === 'lg') return 6
15
+ if (props.size === 'wide') return 4
16
+ return 3
17
+ })
18
+
19
+ const displayedProjects = computed(() => visibleProjects.value.slice(0, maxItems.value))
20
+
21
+ function navigate(id: number) {
22
+ if (props.editing) return
23
+ router.push(`/projects/${id}`)
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <UCard class="h-full overflow-hidden">
29
+ <template #header>
30
+ <div class="flex items-center gap-2">
31
+ <UIcon name="i-heroicons-rectangle-stack" class="text-gray-400" />
32
+ <span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('nav.projects', 'Projects') }}</span>
33
+ <UBadge v-if="visibleProjects.length" :label="String(visibleProjects.length)" color="neutral" variant="soft" size="xs" class="ml-auto" />
34
+ </div>
35
+ </template>
36
+
37
+ <div v-if="pending" class="space-y-2">
38
+ <USkeleton v-for="i in 3" :key="i" class="h-10 w-full" />
39
+ </div>
40
+
41
+ <div v-else-if="!displayedProjects.length" class="flex flex-col items-center justify-center h-full py-6 text-center">
42
+ <UIcon name="i-heroicons-folder-open" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
43
+ <p class="text-sm text-gray-400">{{ t('projects.none', 'No project') }}</p>
44
+ </div>
45
+
46
+ <div
47
+ v-else
48
+ class="overflow-y-auto"
49
+ :class="size === 'wide' ? 'grid grid-cols-2 gap-2' : 'space-y-2'"
50
+ >
51
+ <button
52
+ v-for="project in displayedProjects"
53
+ :key="project.id"
54
+ class="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
55
+ :class="{ 'cursor-default': editing }"
56
+ @click="navigate(project.id)"
57
+ >
58
+ <div
59
+ class="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
60
+ :class="`bg-${project.color || 'primary'}-100 dark:bg-${project.color || 'primary'}-900/30`"
61
+ >
62
+ <UIcon
63
+ name="i-heroicons-folder"
64
+ class="text-sm"
65
+ :class="`text-${project.color || 'primary'}-600`"
66
+ />
67
+ </div>
68
+ <div class="min-w-0 flex-1">
69
+ <p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ project.name }}</p>
70
+ <p class="text-xs text-gray-400">
71
+ {{ project.key_count ?? 0 }} {{ t('translations.keys_count', 'keys') }} · {{ project.language_count ?? 0 }} {{ t('translations.langs_count', 'languages') }}
72
+ </p>
73
+ </div>
74
+ </button>
75
+ </div>
76
+ </UCard>
77
+ </template>