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,295 @@
1
+ <template>
2
+ <div class="space-y-4">
3
+
4
+ <!-- Template picker -->
5
+ <div>
6
+ <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{{ t('plural.template_title', 'Pluralization template') }}</p>
7
+ <div class="grid grid-cols-1 gap-1.5">
8
+ <button
9
+ v-for="tpl in TEMPLATES"
10
+ :key="tpl.id"
11
+ class="flex items-start gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors"
12
+ :class="activeTplId === tpl.id
13
+ ? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700'
14
+ : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
15
+ @click="applyTemplate(tpl)"
16
+ >
17
+ <div class="flex-1 min-w-0">
18
+ <div class="flex items-center gap-2 flex-wrap">
19
+ <span class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ tpl.name }}</span>
20
+ <span class="text-xs text-gray-400">{{ tpl.langs }}</span>
21
+ </div>
22
+ <code class="text-xs font-mono text-green-600 dark:text-green-400 mt-0.5 block truncate">{{ tpl.preview }}</code>
23
+ </div>
24
+ <UBadge size="xs" :color="activeTplId === tpl.id ? 'success' : 'neutral'" variant="soft">
25
+ {{ tpl.forms.length }} {{ t('plural.forms', 'forms') }}
26
+ </UBadge>
27
+ </button>
28
+ </div>
29
+ </div>
30
+
31
+ <!-- Implicit params hint -->
32
+ <div class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
33
+ <UIcon name="i-heroicons-information-circle" class="shrink-0 mt-0.5" />
34
+ <div>
35
+ <span class="font-semibold">{{ t('plural.implicit_params_title', 'Implicit parameters:') }}</span>
36
+ <code class="font-mono mx-1 bg-amber-100 dark:bg-amber-900/40 px-1 rounded">{count}</code> {{ t('plural.and', 'and') }}
37
+ <code class="font-mono mx-1 bg-amber-100 dark:bg-amber-900/40 px-1 rounded">{n}</code>
38
+ {{ t('plural.implicit_params_hint', 'are automatically available in any pluralized key — you don\'t need to pass them explicitly.') }}
39
+ </div>
40
+ </div>
41
+
42
+ <!-- Form fields -->
43
+ <div class="space-y-2">
44
+ <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('plural.forms_title', 'Forms') }}</p>
45
+ <div
46
+ v-for="(form, i) in forms"
47
+ :key="i"
48
+ class="flex items-start gap-3"
49
+ >
50
+ <!-- Label column -->
51
+ <div class="shrink-0 w-28 pt-1.5 space-y-0.5">
52
+ <div class="flex items-center gap-1">
53
+ <span class="text-xs font-mono font-bold text-green-600 dark:text-green-400">
54
+ [{{ i }}]
55
+ </span>
56
+ <span class="text-xs text-gray-500 dark:text-gray-400">{{ formLabel(i) }}</span>
57
+ </div>
58
+ <p class="text-xs text-gray-400 font-mono">count={{ triggerCount(i) }}</p>
59
+ <button
60
+ v-if="forms.length > 2"
61
+ class="text-xs text-gray-300 hover:text-red-400 transition-colors flex items-center gap-0.5"
62
+ @click="removeForm(i)"
63
+ >
64
+ <UIcon name="i-heroicons-trash" class="text-xs" />
65
+ {{ t('plural.remove', 'Remove') }}
66
+ </button>
67
+ </div>
68
+
69
+ <!-- Textarea + implicit param chips -->
70
+ <div class="flex-1 space-y-1">
71
+ <UTextarea
72
+ v-model="forms[i]"
73
+ :rows="2"
74
+ class="w-full text-sm"
75
+ :placeholder="formPlaceholder(i)"
76
+ @input="emitValue"
77
+ />
78
+ <!-- Implicit param quick-insert -->
79
+ <div class="flex gap-1">
80
+ <button
81
+ v-for="p in IMPLICIT_PARAMS"
82
+ :key="p"
83
+ class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-700 hover:bg-amber-100 transition-colors"
84
+ @mousedown.prevent="insertInForm(i, `{${p}}`)"
85
+ >
86
+ <UIcon name="i-heroicons-cursor-arrow-rays" class="text-xs mr-0.5 opacity-60" />
87
+ {{ `{/${p}/}` }}
88
+ </button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <button
94
+ class="text-xs text-green-600 dark:text-green-400 hover:text-green-700 flex items-center gap-1 transition-colors mt-1"
95
+ @click="addForm"
96
+ >
97
+ <UIcon name="i-heroicons-plus-circle" class="text-sm" />
98
+ {{ t('plural.add_form', 'Add a form') }}
99
+ </button>
100
+ </div>
101
+
102
+ <!-- Live preview -->
103
+ <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
104
+ <p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('formats.preview', 'Preview') }}</p>
105
+ <div class="grid grid-cols-3 gap-2">
106
+ <div
107
+ v-for="n in PREVIEW_COUNTS"
108
+ :key="n"
109
+ class="bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 px-2.5 py-2"
110
+ >
111
+ <p class="text-xs text-gray-400 mb-1">count = <strong class="text-gray-600 dark:text-gray-300">{{ n }}</strong></p>
112
+ <p class="text-sm text-gray-700 dark:text-gray-300 font-medium min-h-[1.25rem]">
113
+ <span v-if="resolvePreview(n)">{{ resolvePreview(n) }}</span>
114
+ <span v-else class="text-gray-300 italic text-xs">{{ t('plural.empty', 'empty') }}</span>
115
+ </p>
116
+ </div>
117
+ </div>
118
+ <p class="text-xs text-gray-400">
119
+ {{ t('plural.selection_rule', 'Selection rule: English rule by default (count=1 → form [1] for 2 forms, count=0 → [0], count=1 → [1], count≥2 → [2] for 3 forms).') }}
120
+ </p>
121
+ </div>
122
+
123
+ <!-- Raw value -->
124
+ <div class="flex items-center gap-2 text-xs text-gray-400">
125
+ <span class="shrink-0">{{ t('plural.raw_value', 'Raw value:') }}</span>
126
+ <code class="font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded flex-1 truncate text-gray-600 dark:text-gray-300">
127
+ {{ joinedValue }}
128
+ </code>
129
+ </div>
130
+ </div>
131
+ </template>
132
+
133
+ <script setup lang="ts">
134
+ const { t } = useT()
135
+
136
+ const props = defineProps<{
137
+ modelValue: string
138
+ }>()
139
+
140
+ const emit = defineEmits<{
141
+ 'update:modelValue': [value: string]
142
+ }>()
143
+
144
+ // ── Constants ─────────────────────────────────────────────────────────────────
145
+
146
+ const PLURAL_SEP = ' | '
147
+ const IMPLICIT_PARAMS = ['count', 'n']
148
+ const PREVIEW_COUNTS = [0, 1, 5]
149
+
150
+ const TEMPLATES = computed(() => [
151
+ {
152
+ id: '2-standard',
153
+ name: t('plural.tpl_2_standard', '2 forms — standard'),
154
+ langs: t('plural.tpl_2_langs', 'English, German, Dutch…'),
155
+ preview: 'car | cars',
156
+ forms: ['car', 'cars'],
157
+ // English 2-form rule: 1→[0], else→[1]
158
+ rule: (n: number, len: number) => n === 1 ? 0 : Math.min(1, len - 1),
159
+ },
160
+ {
161
+ id: '3-zero-one-many',
162
+ name: t('plural.tpl_3_zero', '3 forms — zero / one / many'),
163
+ langs: t('plural.tpl_3_langs', 'French, Spanish, Italian…'),
164
+ preview: 'no car | {count} car | {count} cars',
165
+ forms: ['no car', '{count} car', '{count} cars'],
166
+ // French 3-form rule: 0→[0], 1→[1], else→[2]
167
+ rule: (n: number, len: number) => n === 0 ? 0 : n === 1 ? 1 : Math.min(2, len - 1),
168
+ },
169
+ {
170
+ id: '4-slavic',
171
+ name: t('plural.tpl_4_slavic', '4 forms — Slavic languages'),
172
+ langs: t('plural.tpl_4_langs', 'Russian, Polish, Ukrainian…'),
173
+ preview: '0 машин | {n} машина | {n} машины | {n} машин',
174
+ forms: ['0 машин', '{n} машина', '{n} машины', '{n} машин'],
175
+ // Simplified Slavic rule
176
+ rule: (n: number, len: number) => {
177
+ if (n === 0) return 0
178
+ const teen = n > 10 && n < 20
179
+ const end = n % 10
180
+ if (!teen && end === 1) return Math.min(1, len - 1)
181
+ if (!teen && end >= 2 && end <= 4) return Math.min(2, len - 1)
182
+ return Math.min(3, len - 1)
183
+ },
184
+ },
185
+ {
186
+ id: 'custom',
187
+ name: t('plural.tpl_custom', 'Custom'),
188
+ langs: t('plural.tpl_custom_hint', 'Define your own forms'),
189
+ preview: t('plural.tpl_custom_preview', 'form 1 | form 2 | …'),
190
+ forms: ['', ''],
191
+ rule: (n: number, len: number) => n === 1 ? 0 : Math.min(1, len - 1),
192
+ },
193
+ ])
194
+
195
+ // ── State ─────────────────────────────────────────────────────────────────────
196
+
197
+ const forms = ref<string[]>(['', ''])
198
+ const activeTplId = ref<string>('2-standard')
199
+
200
+ // Active template rule for preview
201
+ const activeRule = computed(
202
+ () => TEMPLATES.value.find(t => t.id === activeTplId.value)?.rule
203
+ ?? TEMPLATES.value[0].rule,
204
+ )
205
+
206
+ // ── Init from modelValue ──────────────────────────────────────────────────────
207
+
208
+ watch(
209
+ () => props.modelValue,
210
+ (val) => {
211
+ if (!val) return
212
+ const split = val.split(PLURAL_SEP)
213
+ forms.value = split
214
+ // Auto-detect template
215
+ if (split.length === 4) activeTplId.value = '4-slavic'
216
+ else if (split.length === 3) activeTplId.value = '3-zero-one-many'
217
+ else activeTplId.value = '2-standard'
218
+ },
219
+ { immediate: true },
220
+ )
221
+
222
+ // ── Computed ──────────────────────────────────────────────────────────────────
223
+
224
+ const joinedValue = computed(() => forms.value.join(PLURAL_SEP))
225
+
226
+ // ── Label helpers ─────────────────────────────────────────────────────────────
227
+
228
+ function formLabel(index: number): string {
229
+ const len = forms.value.length
230
+ if (len === 2) return index === 0 ? t('plural.singular', 'singular') : t('plural.plural_form', 'plural')
231
+ if (len === 3) return [t('plural.zero', 'zero'), t('plural.singular', 'singular'), t('plural.plural_form', 'plural')][index] ?? `${t('plural.form', 'form')} ${index}`
232
+ if (len === 4) return [t('plural.zero', 'zero'), t('plural.singular', 'singular'), t('plural.few', 'few'), t('plural.plural_form', 'plural')][index] ?? `${t('plural.form', 'form')} ${index}`
233
+ return `${t('plural.form', 'form')} ${index}`
234
+ }
235
+
236
+ function triggerCount(index: number): string {
237
+ const len = forms.value.length
238
+ if (len === 2) return index === 0 ? '1' : '0, 2, 3…'
239
+ if (len === 3) return ['0', '1', '2, 3…'][index] ?? '…'
240
+ if (len === 4) return ['0', 'x1 (excl. 11)', 'x2–4', t('plural.others', 'others')][index] ?? '…'
241
+ return '…'
242
+ }
243
+
244
+ function formPlaceholder(index: number): string {
245
+ const len = forms.value.length
246
+ if (len === 2) return index === 0 ? 'car' : 'cars'
247
+ if (len === 3) {
248
+ return ['no car', 'one car', '{count} cars'][index] ?? ''
249
+ }
250
+ return `${t('plural.form', 'form')} ${index + 1}`
251
+ }
252
+
253
+ // ── Preview ───────────────────────────────────────────────────────────────────
254
+
255
+ function resolvePreview(n: number): string {
256
+ const len = forms.value.length
257
+ if (!len) return ''
258
+ const idx = activeRule.value(n, len)
259
+ const raw = forms.value[idx] ?? forms.value[len - 1] ?? ''
260
+ return raw.replace(/\{count\}/g, String(n)).replace(/\{n\}/g, String(n))
261
+ }
262
+
263
+ // ── Actions ───────────────────────────────────────────────────────────────────
264
+
265
+ function applyTemplate(tpl: ReturnType<typeof TEMPLATES.value[0]['rule']> extends Function ? any : any) {
266
+ activeTplId.value = tpl.id
267
+ if (tpl.id !== 'custom') {
268
+ forms.value = [...tpl.forms]
269
+ } else {
270
+ forms.value = ['', '']
271
+ }
272
+ emitValue()
273
+ }
274
+
275
+ function addForm() {
276
+ forms.value.push('')
277
+ activeTplId.value = 'custom'
278
+ emitValue()
279
+ }
280
+
281
+ function removeForm(index: number) {
282
+ if (forms.value.length <= 2) return
283
+ forms.value.splice(index, 1)
284
+ emitValue()
285
+ }
286
+
287
+ function insertInForm(formIndex: number, text: string) {
288
+ forms.value[formIndex] = (forms.value[formIndex] ?? '') + text
289
+ emitValue()
290
+ }
291
+
292
+ function emitValue() {
293
+ emit('update:modelValue', joinedValue.value)
294
+ }
295
+ </script>
@@ -0,0 +1,153 @@
1
+ <template>
2
+ <UModal v-model:open="open" :title="t('scan.modal_title', 'Scan project')" :ui="{ width: 'sm:max-w-lg' }">
3
+ <template #body>
4
+ <div class="space-y-5">
5
+
6
+ <!-- Mode tabs -->
7
+ <div class="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
8
+ <button
9
+ v-for="m in modes"
10
+ :key="m.value"
11
+ class="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
12
+ :class="mode === m.value
13
+ ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm'
14
+ : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
15
+ @click="mode = m.value"
16
+ >
17
+ <UIcon :name="m.icon" class="text-sm" />
18
+ {{ m.label }}
19
+ </button>
20
+ </div>
21
+
22
+ <!-- Local mode -->
23
+ <div v-if="mode === 'local'" class="space-y-3">
24
+ <p class="text-sm text-gray-500 dark:text-gray-400">
25
+ {{ t('scan.local_hint', 'Select the root folder of your Vue.js project. The scanner will detect all $t(), t(), <i18n-t> and v-t usages.') }}
26
+ </p>
27
+ <UFormField :label="t('scan.local_path_label', 'Project root folder')">
28
+ <PathPicker v-model="localPath" class="w-full" />
29
+ </UFormField>
30
+ </div>
31
+
32
+ <!-- URL mode -->
33
+ <div v-if="mode === 'url'" class="space-y-3">
34
+ <p class="text-sm text-gray-500 dark:text-gray-400">
35
+ {{ t('scan.url_hint', 'Enter the base URL of your app. The scanner will fetch each configured locale file (en.json, fr.json…) and import all keys it finds.') }}
36
+ </p>
37
+ <UFormField :label="t('scan.url_label', 'Base URL')" :hint="t('scan.url_hint2', 'Example: https://my-app.com')">
38
+ <UInput v-model="remoteUrl" class="w-full" placeholder="https://my-app.com" />
39
+ </UFormField>
40
+ <div v-if="!project?.languages?.length" class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
41
+ <UIcon name="i-heroicons-exclamation-triangle" class="shrink-0 mt-0.5" />
42
+ {{ t('scan.url_no_langs', 'No languages configured on this project yet. Add languages first so the scanner knows which locale files to fetch.') }}
43
+ </div>
44
+ <div v-else class="flex flex-wrap gap-1.5">
45
+ <UBadge v-for="lang in project.languages" :key="lang.code" color="neutral" variant="soft" size="xs">
46
+ <UIcon name="i-heroicons-document-text" class="mr-1 opacity-60" />
47
+ {{ lang.code }}.json
48
+ </UBadge>
49
+ </div>
50
+ </div>
51
+
52
+ <!-- Results -->
53
+ <div v-if="result" class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
54
+ <p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('scan.results', 'Results') }}</p>
55
+ <div class="grid grid-cols-2 gap-2">
56
+ <div class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
57
+ <p class="text-xl font-bold text-gray-900 dark:text-white">{{ result.keysFound ?? result.keysImported }}</p>
58
+ <p class="text-xs text-gray-400">{{ t('scan.keys_found', 'keys found') }}</p>
59
+ </div>
60
+ <div class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
61
+ <p class="text-xl font-bold text-green-600 dark:text-green-400">{{ result.keysAdded }}</p>
62
+ <p class="text-xs text-gray-400">{{ t('scan.keys_added', 'new keys') }}</p>
63
+ </div>
64
+ <div v-if="result.unusedKeys !== undefined" class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
65
+ <p class="text-xl font-bold text-amber-500">{{ result.unusedKeys }}</p>
66
+ <p class="text-xs text-gray-400">{{ t('scan.unused', 'unused') }}</p>
67
+ </div>
68
+ <div v-if="result.scannedFiles !== undefined" class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
69
+ <p class="text-xl font-bold text-gray-900 dark:text-white">{{ result.scannedFiles }}</p>
70
+ <p class="text-xs text-gray-400">{{ t('scan.files_scanned', 'files scanned') }}</p>
71
+ </div>
72
+ </div>
73
+ <p v-if="result.errors?.length" class="text-xs text-red-500">
74
+ {{ result.errors.length }} {{ t('scan.errors', 'errors') }}
75
+ </p>
76
+ </div>
77
+
78
+ <p v-if="error" class="text-sm text-red-500">{{ error }}</p>
79
+ </div>
80
+ </template>
81
+
82
+ <template #footer>
83
+ <div class="flex justify-end gap-2">
84
+ <UButton color="neutral" variant="ghost" @click="open = false">{{ t('common.cancel', 'Cancel') }}</UButton>
85
+ <UButton
86
+ :loading="loading"
87
+ :disabled="mode === 'local' ? !localPath : !remoteUrl"
88
+ icon="i-heroicons-magnifying-glass"
89
+ @click="runScan"
90
+ >
91
+ {{ t('scan.run', 'Scan') }}
92
+ </UButton>
93
+ </div>
94
+ </template>
95
+ </UModal>
96
+ </template>
97
+
98
+ <script setup lang="ts">
99
+ const { t } = useT()
100
+
101
+ const props = defineProps<{
102
+ projectId: number
103
+ project?: { languages?: { code: string; name: string }[]; root_path?: string; source_url?: string }
104
+ }>()
105
+
106
+ const emit = defineEmits<{ done: [] }>()
107
+
108
+ const open = defineModel<boolean>('open', { default: false })
109
+
110
+ const mode = ref<'local' | 'url'>('local')
111
+ const localPath = ref(props.project?.root_path ?? '')
112
+ const remoteUrl = ref(props.project?.source_url ?? '')
113
+ const loading = ref(false)
114
+ const result = ref<any>(null)
115
+ const error = ref('')
116
+
117
+ const modes = computed(() => [
118
+ { value: 'local', label: t('scan.mode_local', 'Local'), icon: 'i-heroicons-computer-desktop' },
119
+ { value: 'url', label: t('scan.mode_url', 'Via URL'), icon: 'i-heroicons-globe-alt' },
120
+ ])
121
+
122
+ watch(open, (val) => {
123
+ if (val) {
124
+ result.value = null
125
+ error.value = ''
126
+ localPath.value = props.project?.root_path ?? ''
127
+ remoteUrl.value = props.project?.source_url ?? ''
128
+ mode.value = props.project?.root_path ? 'local' : props.project?.source_url ? 'url' : 'local'
129
+ }
130
+ })
131
+
132
+ async function runScan() {
133
+ loading.value = true
134
+ error.value = ''
135
+ result.value = null
136
+ try {
137
+ result.value = await $fetch('/api/scan', {
138
+ method: 'POST',
139
+ body: {
140
+ project_id: props.projectId,
141
+ mode: mode.value,
142
+ root_path: mode.value === 'local' ? localPath.value : undefined,
143
+ url: mode.value === 'url' ? remoteUrl.value : undefined,
144
+ },
145
+ })
146
+ emit('done')
147
+ } catch (e: any) {
148
+ error.value = e?.data?.message ?? t('common.error', 'Error')
149
+ } finally {
150
+ loading.value = false
151
+ }
152
+ }
153
+ </script>
@@ -0,0 +1,66 @@
1
+ <template>
2
+ <UModal :open="true" title="Translation History" @update:open="$emit('close')">
3
+ <template #body>
4
+ <div v-if="pending" class="space-y-3">
5
+ <USkeleton v-for="i in 3" :key="i" class="h-16" />
6
+ </div>
7
+
8
+ <div v-else-if="!history?.length" class="text-center py-8">
9
+ <UIcon name="i-heroicons-clock" class="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
10
+ <p class="text-gray-400">No history available</p>
11
+ </div>
12
+
13
+ <div v-else class="space-y-3 max-h-96 overflow-y-auto">
14
+ <div
15
+ v-for="entry in history"
16
+ :key="entry.id"
17
+ class="border border-gray-200 dark:border-gray-700 rounded-lg p-3"
18
+ >
19
+ <div class="flex items-center justify-between mb-2">
20
+ <UBadge
21
+ :color="entry.changed_by === 'google-translate' ? 'info' : entry.changed_by === 'sync' ? 'warning' : 'success'"
22
+ size="xs"
23
+ >
24
+ {{ entry.changed_by === 'google-translate' ? 'Google Translate' : entry.changed_by === 'sync' ? 'File Sync' : 'Manual' }}
25
+ </UBadge>
26
+ <span class="text-xs text-gray-400">{{ formatDate(entry.changed_at) }}</span>
27
+ </div>
28
+
29
+ <div class="grid grid-cols-2 gap-2">
30
+ <div>
31
+ <p class="text-xs text-gray-400 mb-1">Before</p>
32
+ <p class="text-sm text-gray-600 dark:text-gray-400 bg-red-50 dark:bg-red-900/20 rounded px-2 py-1 min-h-8">
33
+ {{ entry.old_value || '—' }}
34
+ </p>
35
+ </div>
36
+ <div>
37
+ <p class="text-xs text-gray-400 mb-1">After</p>
38
+ <p class="text-sm text-gray-800 dark:text-gray-200 bg-green-50 dark:bg-green-900/20 rounded px-2 py-1 min-h-8">
39
+ {{ entry.new_value || '—' }}
40
+ </p>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </template>
46
+ <template #footer>
47
+ <UButton color="neutral" variant="outline" @click="$emit('close')">Close</UButton>
48
+ </template>
49
+ </UModal>
50
+ </template>
51
+
52
+ <script setup lang="ts">
53
+ const props = defineProps<{
54
+ translationId: number | null
55
+ }>()
56
+
57
+ defineEmits<{ close: [] }>()
58
+
59
+ const { data: history, pending } = await useFetch(
60
+ () => props.translationId ? `/api/history/${props.translationId}` : null,
61
+ )
62
+
63
+ function formatDate(date: string) {
64
+ return new Date(date).toLocaleString()
65
+ }
66
+ </script>