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,541 @@
1
+ <template>
2
+ <div
3
+ class="grid border-b border-gray-100 dark:border-gray-800 last:border-0 group/row hover:bg-gray-50/50 dark:hover:bg-gray-800/30 transition-colors"
4
+ :class="translationKey.is_unused ? 'opacity-60' : ''"
5
+ :style="gridStyle"
6
+ >
7
+ <!-- Key column -->
8
+ <div class="px-4 py-3 flex flex-col justify-center min-w-0">
9
+ <div class="flex items-center gap-2 min-w-0">
10
+ <UTooltip v-if="translationKey.is_unused" :text="t('translations.unused_tooltip', 'Key not found in source code')">
11
+ <UIcon name="i-heroicons-exclamation-triangle" class="text-orange-400 text-sm shrink-0" />
12
+ </UTooltip>
13
+ <NuxtLink
14
+ :to="projectId ? `/projects/${projectId}/translations/${translationKey.id}` : `/keys/${translationKey.id}`"
15
+ class="text-sm font-mono font-medium text-gray-900 dark:text-gray-100 truncate hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
16
+ >
17
+ {{ translationKey.key }}
18
+ </NuxtLink>
19
+ </div>
20
+ <!-- Description: inline edit -->
21
+ <template v-if="editingDescription">
22
+ <div class="flex items-center gap-1 mt-0.5">
23
+ <UInput
24
+ v-model="descriptionDraft"
25
+ size="xs"
26
+ class="flex-1 text-xs"
27
+ placeholder="Description…"
28
+ autofocus
29
+ @keydown.enter="saveDescription"
30
+ @keydown.escape="cancelDescription"
31
+ />
32
+ <UButton size="xs" @click="saveDescription" :loading="savingDescription">OK</UButton>
33
+ <UButton size="xs" color="neutral" variant="ghost" @click="cancelDescription">✕</UButton>
34
+ </div>
35
+ </template>
36
+ <template v-else>
37
+ <p
38
+ class="text-xs mt-0.5 truncate cursor-pointer"
39
+ :class="translationKey.description ? 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300' : 'text-gray-300 dark:text-gray-600 italic hover:text-primary-400'"
40
+ @click="startEditDescription"
41
+ >
42
+ {{ translationKey.description || t('translations.add_description', 'Add a description…') }}
43
+ </p>
44
+ </template>
45
+ <!-- Usage info -->
46
+ <div v-if="translationKey.usages?.length" class="flex items-center gap-1 mt-1">
47
+ <UTooltip :text="usageTooltip">
48
+ <button class="flex items-center gap-1 text-xs text-gray-400 hover:text-primary-500 transition-colors">
49
+ <UIcon name="i-heroicons-code-bracket" class="text-xs" />
50
+ <span>{{ translationKey.usages.length }} {{ translationKey.usages.length > 1 ? t('translations.references', 'references') : t('translations.reference', 'reference') }}</span>
51
+ </button>
52
+ </UTooltip>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Translation columns -->
57
+ <div
58
+ v-for="lang in languages"
59
+ :key="lang.code"
60
+ class="px-3 py-2 flex items-start gap-2 min-w-0 group/cell"
61
+ >
62
+ <div class="flex-1 min-w-0">
63
+ <!-- Edit mode -->
64
+ <template v-if="editingCell === `${translationKey.id}-${lang.code}`">
65
+ <div :ref="el => textareaWrappers[lang.code] = el as HTMLElement">
66
+ <UTextarea
67
+ v-model="editValues[lang.code]"
68
+ :rows="2"
69
+ autofocus
70
+ class="text-sm w-full"
71
+ @keydown.ctrl.enter="saveTranslation(lang.code)"
72
+ @keydown.meta.enter="saveTranslation(lang.code)"
73
+ @keydown.escape="cancelEdit(lang.code)"
74
+ />
75
+ </div>
76
+ <!-- Insertion helpers toolbar -->
77
+ <div class="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1.5 pb-1.5 border-b border-gray-100 dark:border-gray-800">
78
+ <!-- Named params -->
79
+ <template v-if="detectedParams.length">
80
+ <div class="flex items-center gap-1 flex-wrap">
81
+ <span class="text-xs text-gray-400">{{ t('key.params_label', 'Params:') }}</span>
82
+ <button
83
+ v-for="param in detectedParams"
84
+ :key="param"
85
+ class="inline-flex items-center gap-0.5 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 dark:hover:bg-amber-900/50 transition-colors"
86
+ @mousedown.prevent="insertAtCursor(lang.code, '{' + param + '}')"
87
+ >
88
+ <UIcon name="i-heroicons-cursor-arrow-rays" class="text-xs opacity-60" />
89
+ {{ '{' + param + '}' }}
90
+ </button>
91
+ </div>
92
+ </template>
93
+ <!-- Literal / backslash escapes -->
94
+ <div class="flex items-center gap-1 flex-wrap">
95
+ <span class="text-xs text-gray-400">{{ t('key.escapes_label', 'Escapes:') }}</span>
96
+ <UTooltip v-for="esc in ALL_ESCAPES" :key="esc.insert" :text="esc.hint">
97
+ <button
98
+ class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-700 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors"
99
+ @mousedown.prevent="insertAtCursor(lang.code, esc.insert)"
100
+ >{{ esc.label }}</button>
101
+ </UTooltip>
102
+ </div>
103
+ <!-- Modifiers -->
104
+ <div class="flex items-center gap-1 flex-wrap">
105
+ <span class="text-xs text-gray-400">{{ t('key.modifiers_label', 'Modifiers:') }}</span>
106
+ <UTooltip v-for="mod in LINK_MODIFIERS" :key="mod.prefix" :text="mod.hint">
107
+ <button
108
+ class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-violet-50 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-700 hover:bg-violet-100 dark:hover:bg-violet-900/50 transition-colors"
109
+ @mousedown.prevent="insertAtCursor(lang.code, mod.prefix)"
110
+ >{{ mod.prefix }}</button>
111
+ </UTooltip>
112
+ </div>
113
+ <!-- Plural separator -->
114
+ <UTooltip :text="t('translations.insert_plural_sep', 'Insert a plural form separator | (e.g. car | cars)')">
115
+ <button
116
+ class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-mono bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-700 hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors"
117
+ @mousedown.prevent="insertAtCursor(lang.code, ' | ')"
118
+ >
119
+ <UIcon name="i-heroicons-bars-3-bottom-left" class="text-xs opacity-60" />
120
+ {{ ' | ' }}
121
+ </button>
122
+ </UTooltip>
123
+ <!-- Linked key picker -->
124
+ <LinkedKeyPicker :project-id="projectId" @select="(val) => insertLinkedKey(lang.code, val)" />
125
+ </div>
126
+ <div class="flex gap-1 mt-1.5">
127
+ <UButton
128
+ size="xs"
129
+ @click="saveTranslation(lang.code)"
130
+ :loading="saving === `${translationKey.id}-${lang.code}`"
131
+ >
132
+ {{ t('translations.save', 'Save') }}
133
+ </UButton>
134
+ <UButton size="xs" color="neutral" variant="ghost" @click="cancelEdit(lang.code)">
135
+ {{ t('common.cancel', 'Cancel') }}
136
+ </UButton>
137
+ </div>
138
+ </template>
139
+
140
+ <!-- View mode -->
141
+ <template v-else>
142
+ <div class="flex items-start gap-1.5">
143
+ <!-- Status dot -->
144
+ <UTooltip :text="statusLabel(lang.code)" :delay-duration="300">
145
+ <span
146
+ class="mt-1.5 w-2 h-2 rounded-full shrink-0"
147
+ :class="[statusDot(lang.code), canClickStatus(lang.code) ? 'cursor-pointer' : 'cursor-default']"
148
+ @click="cycleStatus(lang.code)"
149
+ />
150
+ </UTooltip>
151
+
152
+ <div class="flex-1 min-w-0 cursor-pointer" @click="startEdit(lang.code)">
153
+ <template v-if="getTranslation(lang.code)">
154
+ <!-- Plural: show forms stacked -->
155
+ <div v-if="getPluralCount(lang.code) > 1" class="space-y-0.5">
156
+ <div
157
+ v-for="(form, i) in getTranslation(lang.code).split(' | ')"
158
+ :key="i"
159
+ class="flex items-baseline gap-1.5"
160
+ >
161
+ <span class="text-xs font-mono text-green-500 shrink-0">[{{ i }}]</span>
162
+ <span class="text-sm text-gray-700 dark:text-gray-300 leading-snug truncate hover:text-primary-600 dark:hover:text-primary-400">{{ form.trim() }}</span>
163
+ </div>
164
+ </div>
165
+ <!-- Single value -->
166
+ <p
167
+ v-else
168
+ class="text-sm text-gray-700 dark:text-gray-300 leading-snug truncate hover:text-primary-600 dark:hover:text-primary-400"
169
+ >{{ getTranslation(lang.code) }}</p>
170
+ </template>
171
+ <span v-else class="text-xs text-gray-300 dark:text-gray-600 italic hover:text-primary-500 transition-colors">
172
+ {{ t('translations.click_to_add', 'Click to add…') }}
173
+ </span>
174
+ </div>
175
+ </div>
176
+ </template>
177
+ </div>
178
+
179
+ <!-- Actions: Google Translate + status toggle -->
180
+ <div class="flex flex-col gap-1 opacity-0 group-hover/cell:opacity-100 transition-opacity shrink-0">
181
+ <UTooltip :text="hasSourceText ? t('translations.translate_to', 'Translate to') + ` ${lang.name}` : t('translations.no_source', 'No source available')">
182
+ <UButton
183
+ icon="i-heroicons-sparkles"
184
+ size="xs"
185
+ color="warning"
186
+ variant="ghost"
187
+ :disabled="!hasSourceText"
188
+ :loading="translateLoading === `${translationKey.id}-${lang.code}`"
189
+ @click="autoTranslate(lang)"
190
+ />
191
+ </UTooltip>
192
+ </div>
193
+ </div>
194
+
195
+ <!-- Row actions -->
196
+ <div class="px-2 py-3 flex items-center justify-center">
197
+ <UDropdownMenu :items="rowActions">
198
+ <UButton
199
+ icon="i-heroicons-ellipsis-vertical"
200
+ size="xs"
201
+ color="neutral"
202
+ variant="ghost"
203
+ class="opacity-0 group-hover/row:opacity-100 transition-opacity"
204
+ />
205
+ </UDropdownMenu>
206
+ </div>
207
+ </div>
208
+
209
+ <!-- History modal -->
210
+ <TranslationHistoryModal
211
+ v-if="showHistory"
212
+ :translation-id="historyTranslationId"
213
+ @close="showHistory = false"
214
+ />
215
+ </template>
216
+
217
+ <script setup lang="ts">
218
+ import { TRANSLATION_STATUS } from '../server/enums/translation.enum'
219
+
220
+ const { t } = useT()
221
+
222
+ const props = defineProps<{
223
+ translationKey: {
224
+ id: number
225
+ key: string
226
+ description?: string
227
+ is_unused?: boolean
228
+ translations: Record<string, { id: number; value: string; language_code: string; status?: string } | undefined>
229
+ usages?: Array<{ file_path: string; line_number: number; detected_function: string }>
230
+ }
231
+ languages: Array<{ code: string; name: string }>
232
+ gridStyle: Record<string, string>
233
+ projectId?: number
234
+ }>()
235
+
236
+ const emit = defineEmits<{ updated: [] }>()
237
+
238
+ const toast = useToast()
239
+ const { canApprove, canManageProject } = useAuth()
240
+
241
+ const userCanApprove = computed(() => props.projectId ? canApprove(props.projectId) : false)
242
+ const userCanDelete = computed(() => props.projectId ? canManageProject(props.projectId) : false)
243
+
244
+ // Description inline edit
245
+ const editingDescription = ref(false)
246
+ const descriptionDraft = ref('')
247
+ const savingDescription = ref(false)
248
+
249
+ function startEditDescription() {
250
+ descriptionDraft.value = props.translationKey.description || ''
251
+ editingDescription.value = true
252
+ }
253
+
254
+ function cancelDescription() {
255
+ editingDescription.value = false
256
+ }
257
+
258
+ async function saveDescription() {
259
+ savingDescription.value = true
260
+ try {
261
+ await $fetch(`/api/keys/${props.translationKey.id}`, {
262
+ method: 'PATCH',
263
+ body: { description: descriptionDraft.value || null },
264
+ })
265
+ editingDescription.value = false
266
+ emit('updated')
267
+ } catch (e: any) {
268
+ toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
269
+ } finally {
270
+ savingDescription.value = false
271
+ }
272
+ }
273
+
274
+ const editingCell = ref<string | null>(null)
275
+ const editValues = ref<Record<string, string>>({})
276
+ const saving = ref<string | null>(null)
277
+ const translateLoading = ref<string | null>(null)
278
+ const showHistory = ref(false)
279
+ const historyTranslationId = ref<number | null>(null)
280
+ const textareaWrappers = ref<Record<string, HTMLElement | null>>({})
281
+
282
+ // Literal interpolation {'x'} + backslash escapes (v11.3+), grouped
283
+ const ALL_ESCAPES = computed(() => [
284
+ { label: `{'@'}`, insert: `{'@'}`, hint: t('key.escape_at', `@ → {'@'} — prevents link interpretation`) },
285
+ { label: `{'{'}`, insert: `{'{'}`, hint: t('key.escape_open_brace', `{ → {'{'} — prevents interpolation opening`) },
286
+ { label: `{'}'}`, insert: `{'}'}`, hint: t('key.escape_close_brace', `} → {'}'} — prevents interpolation closing`) },
287
+ { label: `{'$'}`, insert: `{'$'}`, hint: t('key.escape_dollar', `$ → {'$'} — prevents modifier interpretation`) },
288
+ { label: `{'|'}`, insert: `{'|'}`, hint: t('key.escape_pipe', `| → {'|'} — literal pipe (≠ plural separator)`) },
289
+ { label: `\\{`, insert: `\\{`, hint: t('key.escape_backslash_open', `\\{ — backslash escape (v11.3+), alternative to {'{'}`) },
290
+ { label: `\\}`, insert: `\\}`, hint: t('key.escape_backslash_close', `\\} — backslash escape (v11.3+), alternative to {'}'}`) },
291
+ { label: `\\@`, insert: `\\@`, hint: t('key.escape_backslash_at', `\\@ — backslash escape (v11.3+), alternative to {'@'}`) },
292
+ { label: `\\\\`, insert: `\\\\`, hint: t('key.escape_backslash', `\\\\ — literal backslash`) },
293
+ ])
294
+
295
+ // Modifier prefixes for linked messages
296
+ const LINK_MODIFIERS = computed(() => [
297
+ { prefix: '@:', hint: t('key.modifier_simple', '@:key — inserts the value as-is') },
298
+ { prefix: '@.lower:', hint: t('key.modifier_lower', '@.lower:key — converts to lowercase') },
299
+ { prefix: '@.upper:', hint: t('key.modifier_upper', '@.upper:key — converts to UPPERCASE') },
300
+ { prefix: '@.capitalize:', hint: t('key.modifier_capitalize', '@.capitalize:key — capitalizes first letter') },
301
+ ])
302
+
303
+ // Detect named/list params like {name}, {0} across all existing translations
304
+ const PARAM_REGEX = /\{([a-zA-Z_$][a-zA-Z0-9_\-$]*|\d+)\}/g
305
+ const detectedParams = computed(() => {
306
+ const params = new Set<string>()
307
+ for (const tr of Object.values(props.translationKey.translations)) {
308
+ if (!tr?.value) continue
309
+ for (const match of tr.value.matchAll(PARAM_REGEX)) {
310
+ params.add(match[1])
311
+ }
312
+ }
313
+ return [...params]
314
+ })
315
+
316
+ function insertAtCursor(langCode: string, insertion: string) {
317
+ const wrapper = textareaWrappers.value[langCode]
318
+ const textarea = wrapper?.querySelector('textarea')
319
+ const current = editValues.value[langCode] || ''
320
+
321
+ if (textarea) {
322
+ const start = textarea.selectionStart ?? current.length
323
+ const end = textarea.selectionEnd ?? current.length
324
+ editValues.value[langCode] = current.slice(0, start) + insertion + current.slice(end)
325
+ nextTick(() => {
326
+ textarea.focus()
327
+ textarea.selectionStart = textarea.selectionEnd = start + insertion.length
328
+ })
329
+ } else {
330
+ editValues.value[langCode] = current + insertion
331
+ }
332
+ }
333
+
334
+ function insertLinkedKey(langCode: string, value: string) {
335
+ insertAtCursor(langCode, value)
336
+ }
337
+
338
+ function getTranslation(langCode: string): string {
339
+ return props.translationKey.translations[langCode]?.value || ''
340
+ }
341
+
342
+ function getPluralCount(langCode: string): number {
343
+ const val = getTranslation(langCode)
344
+ return val ? val.split(' | ').length : 0
345
+ }
346
+
347
+ function getStatus(langCode: string): TRANSLATION_STATUS | null {
348
+ const tr = props.translationKey.translations[langCode]
349
+ if (!tr?.value) return null
350
+ return (tr.status as TRANSLATION_STATUS) || TRANSLATION_STATUS.DRAFT
351
+ }
352
+
353
+ function statusDot(langCode: string): string {
354
+ const status = getStatus(langCode)
355
+ if (!status) return 'bg-gray-200 dark:bg-gray-700'
356
+ const map: Record<TRANSLATION_STATUS, string> = {
357
+ [TRANSLATION_STATUS.DRAFT]: 'bg-yellow-400',
358
+ [TRANSLATION_STATUS.REVIEWED]: 'bg-blue-400',
359
+ [TRANSLATION_STATUS.APPROVED]: 'bg-green-500',
360
+ [TRANSLATION_STATUS.REJECTED]: 'bg-red-400',
361
+ }
362
+ return map[status] || 'bg-gray-200'
363
+ }
364
+
365
+ function statusLabel(langCode: string): string {
366
+ const status = getStatus(langCode)
367
+ if (!status) return t('translations.status_missing', 'Missing — click to add')
368
+ if (status === TRANSLATION_STATUS.DRAFT) return userCanApprove.value
369
+ ? t('translations.status_draft_approver', 'Draft — click to mark as reviewed')
370
+ : t('translations.status_draft_translator', 'Draft — click to mark as reviewed')
371
+ if (status === TRANSLATION_STATUS.REVIEWED) return userCanApprove.value
372
+ ? t('translations.status_reviewed_approver', 'Reviewed — click to approve')
373
+ : t('translations.status_reviewed_translator', 'Reviewed (approval reserved for moderators)')
374
+ if (status === TRANSLATION_STATUS.APPROVED) return userCanApprove.value
375
+ ? t('translations.status_approved_approver', 'Approved — click to revert to draft')
376
+ : t('translations.status_approved', 'Approved')
377
+ if (status === TRANSLATION_STATUS.REJECTED) return t('status.rejected', 'Rejected')
378
+ return status
379
+ }
380
+
381
+ function canClickStatus(langCode: string): boolean {
382
+ const tr = props.translationKey.translations[langCode]
383
+ if (!tr?.value) return false
384
+ if (!userCanApprove.value && tr.status === TRANSLATION_STATUS.APPROVED) return false
385
+ return true
386
+ }
387
+
388
+ async function cycleStatus(langCode: string) {
389
+ const tr = props.translationKey.translations[langCode]
390
+ if (!tr?.value) return
391
+
392
+ const current = (tr.status as TRANSLATION_STATUS) || TRANSLATION_STATUS.DRAFT
393
+ let next: TRANSLATION_STATUS
394
+ if (userCanApprove.value) {
395
+ // moderator+ can cycle through all statuses
396
+ next = current === TRANSLATION_STATUS.DRAFT ? TRANSLATION_STATUS.REVIEWED : current === TRANSLATION_STATUS.REVIEWED ? TRANSLATION_STATUS.APPROVED : TRANSLATION_STATUS.DRAFT
397
+ } else {
398
+ // translator can only toggle draft ↔ reviewed
399
+ if (current === TRANSLATION_STATUS.APPROVED) return // cannot change approved
400
+ next = current === TRANSLATION_STATUS.DRAFT ? TRANSLATION_STATUS.REVIEWED : TRANSLATION_STATUS.DRAFT
401
+ }
402
+
403
+ try {
404
+ await $fetch('/api/translations/status', {
405
+ method: 'POST',
406
+ body: { key_id: props.translationKey.id, language_code: langCode, status: next },
407
+ })
408
+ emit('updated')
409
+ refreshNuxtData('project-stats')
410
+ } catch (e: any) {
411
+ toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
412
+ }
413
+ }
414
+
415
+ const hasSourceText = computed(() =>
416
+ Object.values(props.translationKey.translations).some((tr) => tr?.value),
417
+ )
418
+
419
+ function getSourceText(): { text: string; lang: string } | null {
420
+ const entries = Object.entries(props.translationKey.translations)
421
+ const withValue = entries.filter(([, tr]) => tr?.value)
422
+ if (!withValue.length) return null
423
+ return { text: withValue[0][1]!.value, lang: withValue[0][0] }
424
+ }
425
+
426
+ const usageTooltip = computed(() => {
427
+ const usages = props.translationKey.usages || []
428
+ // Group by file
429
+ const byFile = usages.reduce((acc: Record<string, number[]>, u) => {
430
+ if (!acc[u.file_path]) acc[u.file_path] = []
431
+ acc[u.file_path].push(u.line_number)
432
+ return acc
433
+ }, {})
434
+ return Object.entries(byFile)
435
+ .slice(0, 5)
436
+ .map(([file, lines]) => `${file}:${lines.join(',')}`)
437
+ .join('\n')
438
+ })
439
+
440
+ function startEdit(langCode: string) {
441
+ editingCell.value = `${props.translationKey.id}-${langCode}`
442
+ editValues.value[langCode] = getTranslation(langCode)
443
+ }
444
+
445
+ function cancelEdit(langCode: string) {
446
+ editingCell.value = null
447
+ delete editValues.value[langCode]
448
+ }
449
+
450
+ async function saveTranslation(langCode: string) {
451
+ saving.value = `${props.translationKey.id}-${langCode}`
452
+ try {
453
+ await $fetch('/api/translations', {
454
+ method: 'POST',
455
+ body: {
456
+ key_id: props.translationKey.id,
457
+ language_code: langCode,
458
+ value: editValues.value[langCode],
459
+ },
460
+ })
461
+ editingCell.value = null
462
+ emit('updated')
463
+ } catch (e: any) {
464
+ toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
465
+ } finally {
466
+ saving.value = null
467
+ }
468
+ }
469
+
470
+ async function autoTranslate(lang: { code: string; name: string }) {
471
+ const source = getSourceText()
472
+ if (!source) return
473
+
474
+ translateLoading.value = `${props.translationKey.id}-${lang.code}`
475
+ try {
476
+ await $fetch('/api/translate', {
477
+ method: 'POST',
478
+ body: {
479
+ text: source.text,
480
+ from: source.lang,
481
+ to: lang.code,
482
+ key_id: props.translationKey.id,
483
+ language_code: lang.code,
484
+ },
485
+ })
486
+ toast.add({ title: t('translations.translated', 'Translated'), description: `${props.translationKey.key} → ${lang.name}`, color: 'success' })
487
+ emit('updated')
488
+ } catch (e: any) {
489
+ toast.add({ title: t('translations.translate_error', 'Google Translate error'), description: e.data?.message || e.message, color: 'error' })
490
+ } finally {
491
+ translateLoading.value = null
492
+ }
493
+ }
494
+
495
+ function viewHistory(langCode: string) {
496
+ const tr = props.translationKey.translations[langCode]
497
+ if (tr) {
498
+ historyTranslationId.value = tr.id
499
+ showHistory.value = true
500
+ }
501
+ }
502
+
503
+ const rowActions = computed(() => {
504
+ const groups: any[] = [
505
+ [
506
+ {
507
+ label: t('translations.history_by_lang', 'History by language'),
508
+ type: 'label' as const,
509
+ },
510
+ ...props.languages.map((lang) => ({
511
+ label: `${lang.name} (${lang.code})`,
512
+ icon: 'i-heroicons-clock',
513
+ disabled: !props.translationKey.translations[lang.code],
514
+ onSelect: () => viewHistory(lang.code),
515
+ })),
516
+ ],
517
+ ]
518
+ if (userCanDelete.value) {
519
+ groups.push([
520
+ {
521
+ label: t('translations.delete_key', 'Delete key'),
522
+ icon: 'i-heroicons-trash',
523
+ color: 'error' as const,
524
+ onSelect: deleteKey,
525
+ },
526
+ ])
527
+ }
528
+ return groups
529
+ })
530
+
531
+ async function deleteKey() {
532
+ try {
533
+ await $fetch(`/api/keys/${props.translationKey.id}`, { method: 'DELETE' })
534
+ toast.add({ title: t('translations.key_deleted', 'Key deleted'), color: 'success' })
535
+ emit('updated')
536
+ refreshNuxtData('project-stats')
537
+ } catch (e: any) {
538
+ toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
539
+ }
540
+ }
541
+ </script>
@@ -0,0 +1,121 @@
1
+ <script lang="ts" setup>
2
+ import type { PropType } from 'vue'
3
+ import type { WidgetConfig, WidgetDataSource, DataSourceType } from '~/types/dashboard.type'
4
+ import { WIDGET_REGISTRY } from '~/consts/dashboard.const'
5
+
6
+ const props = defineProps({
7
+ open: {
8
+ type: Boolean,
9
+ required: true,
10
+ },
11
+ widget: {
12
+ type: Object as PropType<WidgetConfig | null>,
13
+ default: null,
14
+ },
15
+ index: {
16
+ type: Number,
17
+ default: -1,
18
+ },
19
+ })
20
+
21
+ const emit = defineEmits<{
22
+ (e: 'update:open', value: boolean): void
23
+ (e: 'save', value: { dataSource: WidgetDataSource | undefined; title: string | undefined }): void
24
+ }>()
25
+
26
+ const { t } = useT()
27
+ const { visibleProjects } = useProject()
28
+
29
+ const draftSource = ref<DataSourceType>('global')
30
+ const draftProjectId = ref<number | undefined>()
31
+ const draftTitle = ref('')
32
+
33
+ watch(
34
+ () => props.widget,
35
+ (w) => {
36
+ if (!w) return
37
+ draftSource.value = w.dataSource?.type ?? 'global'
38
+ draftProjectId.value = w.dataSource?.projectId
39
+ draftTitle.value = w.title ?? ''
40
+ },
41
+ { immediate: true },
42
+ )
43
+
44
+ const projectItems = computed(() =>
45
+ visibleProjects.value
46
+ .filter((p: any) => !p.is_system)
47
+ .map((p: any) => ({ label: p.name, value: p.id })),
48
+ )
49
+
50
+ const hasDataSource = computed(() => {
51
+ if (!props.widget) return false
52
+ return WIDGET_REGISTRY[props.widget.type].hasDataSource
53
+ })
54
+
55
+ function save() {
56
+ const dataSource: WidgetDataSource = draftSource.value === 'project'
57
+ ? { type: 'project', projectId: draftProjectId.value }
58
+ : { type: 'global' }
59
+ emit('save', { dataSource, title: draftTitle.value || undefined })
60
+ emit('update:open', false)
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <UModal :open="open" :title="t('dashboard.configure_widget', 'Configure widget')" @update:open="emit('update:open', $event)">
66
+ <template #body>
67
+ <div class="space-y-6">
68
+ <div v-if="hasDataSource" class="space-y-3">
69
+ <p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.data_source', 'Data source') }}</p>
70
+ <div class="flex gap-2">
71
+ <button
72
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors"
73
+ :class="draftSource === 'global'
74
+ ? 'bg-primary-500 text-white border-primary-500'
75
+ : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
76
+ @click="draftSource = 'global'"
77
+ >
78
+ Global
79
+ </button>
80
+ <button
81
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors"
82
+ :class="draftSource === 'project'
83
+ ? 'bg-primary-500 text-white border-primary-500'
84
+ : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
85
+ @click="draftSource = 'project'"
86
+ >
87
+ {{ t('dashboard.specific_project', 'Specific project') }}
88
+ </button>
89
+ </div>
90
+
91
+ <div v-if="draftSource === 'project'">
92
+ <USelect
93
+ v-model="draftProjectId"
94
+ :items="projectItems"
95
+ :placeholder="t('dashboard.select_project', 'Select a project')"
96
+ />
97
+ </div>
98
+ </div>
99
+
100
+ <div class="space-y-2">
101
+ <p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.custom_title', 'Custom title') }}</p>
102
+ <UInput
103
+ v-model="draftTitle"
104
+ :placeholder="t('dashboard.default_title', 'Default title')"
105
+ />
106
+ </div>
107
+ </div>
108
+ </template>
109
+
110
+ <template #footer>
111
+ <div class="flex justify-end gap-2">
112
+ <UButton variant="ghost" color="neutral" @click="emit('update:open', false)">
113
+ {{ t('common.cancel', 'Cancel') }}
114
+ </UButton>
115
+ <UButton @click="save">
116
+ {{ t('common.save', 'Save') }}
117
+ </UButton>
118
+ </div>
119
+ </template>
120
+ </UModal>
121
+ </template>