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,250 @@
1
+ <template>
2
+ <div class="p-6">
3
+ <!-- Header -->
4
+ <div class="flex items-center justify-between mb-5">
5
+ <div>
6
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('translations.title', 'Translations') }}</h1>
7
+ <p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
8
+ {{ data?.total || 0 }} {{ t('translations.keys_count', 'keys') }} · {{ languages.length }} {{ t('translations.langs_count', 'languages') }}
9
+ </p>
10
+ </div>
11
+ <div class="flex gap-2">
12
+ <UButton
13
+ v-if="filterStatus === 'missing' && filterLangs.length === 1"
14
+ icon="i-heroicons-sparkles"
15
+ color="warning"
16
+ variant="outline"
17
+ :loading="batchTranslating"
18
+ @click="batchTranslate"
19
+ >
20
+ {{ t('translations.translate_all', 'Translate all') }} ({{ filterLangs[0].toUpperCase() }})
21
+ </UButton>
22
+ <UButton v-if="userCanManage" icon="i-heroicons-plus" @click="showAddKey = true">{{ t('translations.add_key', 'New key') }}</UButton>
23
+ </div>
24
+ </div>
25
+
26
+ <!-- Filters bar -->
27
+ <div class="flex flex-col sm:flex-row gap-3 mb-5">
28
+ <UInput
29
+ v-model="search"
30
+ icon="i-heroicons-magnifying-glass"
31
+ :placeholder="t('translations.search', 'Search for a key...')"
32
+ class="flex-1"
33
+ @input="debouncedRefresh"
34
+ />
35
+ <USelectMenu
36
+ v-model="filterLangs"
37
+ :items="langOptions"
38
+ multiple
39
+ value-key="value"
40
+ :placeholder="filterLangs.length ? `${filterLangs.length} ${t('translations.language', 'language')}${filterLangs.length > 1 ? 's' : ''}` : t('translations.all_languages', 'All languages')"
41
+ class="w-48"
42
+ @update:model-value="refresh"
43
+ />
44
+ </div>
45
+
46
+ <!-- Status legend pills -->
47
+ <div class="flex gap-2 mb-4 flex-wrap">
48
+ <button
49
+ v-for="s in statusFilters"
50
+ :key="s.value"
51
+ class="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all"
52
+ :class="filterStatus === s.value
53
+ ? `${s.activeBg} ${s.activeText} border-transparent`
54
+ : 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-500 hover:border-gray-300'"
55
+ @click="filterStatus = s.value; refresh()"
56
+ >
57
+ <span class="w-2 h-2 rounded-full" :class="s.dot" />
58
+ {{ s.label }}
59
+ </button>
60
+ </div>
61
+
62
+ <!-- Empty state -->
63
+ <div v-if="!pending && !data?.data?.length" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 py-16 text-center">
64
+ <UIcon name="i-heroicons-inbox" class="text-5xl text-gray-300 dark:text-gray-600 mb-3" />
65
+ <p class="text-gray-500 font-medium">{{ t('translations.no_results', 'No keys found') }}</p>
66
+ <p class="text-gray-400 text-sm mt-1">
67
+ <template v-if="search || filterStatus !== 'all'">{{ t('translations.modify_filters', 'Modify your filters or') }} </template>
68
+ {{ t('translations.add_or_scan', 'add a key manually or scan your project.') }}
69
+ </p>
70
+ <div class="flex justify-center gap-3 mt-4">
71
+ <UButton size="sm" @click="showAddKey = true">{{ t('translations.add_key', 'New key') }}</UButton>
72
+ <UButton size="sm" variant="outline" color="neutral" :loading="scanning" @click="scanProject">{{ t('translations.scan', 'Scan') }}</UButton>
73
+ <UButton size="sm" variant="outline" color="neutral" :loading="syncing" @click="syncFiles">{{ t('translations.sync', 'Sync JSON') }}</UButton>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Table -->
78
+ <div v-else class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
79
+ <!-- Table header -->
80
+ <div
81
+ class="grid border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50"
82
+ :style="gridStyle"
83
+ >
84
+ <div class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">{{ t('translations.key_label', 'Key') }}</div>
85
+ <div
86
+ v-for="lang in visibleLanguages"
87
+ :key="lang.code"
88
+ class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-1.5"
89
+ >
90
+ {{ findLanguage(lang.code)?.nativeName || lang.name }}
91
+ <UBadge size="xs" variant="outline" color="neutral">{{ lang.code }}</UBadge>
92
+ </div>
93
+ <div class="px-3 py-3" />
94
+ </div>
95
+
96
+ <!-- Loading -->
97
+ <div v-if="pending">
98
+ <div v-for="i in 6" :key="i" class="grid border-b border-gray-100 dark:border-gray-800 last:border-0" :style="gridStyle">
99
+ <div class="px-4 py-4"><USkeleton class="h-4 w-3/4" /></div>
100
+ <div v-for="j in visibleLanguages.length" :key="j" class="px-4 py-4"><USkeleton class="h-4" /></div>
101
+ <div class="px-3 py-4" />
102
+ </div>
103
+ </div>
104
+
105
+ <!-- Rows -->
106
+ <div v-else>
107
+ <TranslationRow
108
+ v-for="key in data.data"
109
+ :key="key.id"
110
+ :translation-key="key"
111
+ :languages="visibleLanguages"
112
+ :grid-style="gridStyle"
113
+ :project-id="currentProject?.id"
114
+ @updated="refresh"
115
+ />
116
+ </div>
117
+ </div>
118
+
119
+ <!-- Pagination -->
120
+ <div v-if="(data?.total || 0) > limit" class="flex justify-center mt-5">
121
+ <UPagination
122
+ v-model:page="page"
123
+ :total="data?.total || 0"
124
+ :items-per-page="limit"
125
+ @update:page="refresh"
126
+ />
127
+ </div>
128
+
129
+ <!-- Add Key modal -->
130
+ <UModal v-model:open="showAddKey" :title="t('translations.add_key_title', 'New translation key')">
131
+ <template #body>
132
+ <div class="space-y-4">
133
+ <UFormField :label="t('translations.key_label', 'Key')" :hint="t('translations.key_hint', 'Example: home.title or nav.menu.about')" required>
134
+ <UInput v-model="newKey.key" placeholder="home.title" class="w-full font-mono" />
135
+ </UFormField>
136
+ <UFormField :label="t('translations.description_label', 'Description')" :hint="t('translations.description_hint', 'Context for translators')">
137
+ <UInput v-model="newKey.description" :placeholder="t('translations.description_placeholder', 'Home page title')" class="w-full" />
138
+ </UFormField>
139
+ </div>
140
+ </template>
141
+ <template #footer>
142
+ <div class="flex justify-end gap-3">
143
+ <UButton variant="ghost" color="neutral" @click="showAddKey = false">{{ t('common.cancel', 'Cancel') }}</UButton>
144
+ <UButton :loading="addingKey" @click="addKey">{{ t('common.create', 'Create') }}</UButton>
145
+ </div>
146
+ </template>
147
+ </UModal>
148
+ </div>
149
+ </template>
150
+
151
+ <script setup lang="ts">
152
+ import { TRANSLATION_STATUS } from '~/server/enums/translation.enum'
153
+
154
+ const route = useRoute()
155
+ const { currentProject } = useProject()
156
+ const { canManageProject } = useAuth()
157
+ const { findLanguage, projectLanguages: languages } = useLanguages()
158
+ const { t } = useT()
159
+
160
+ const userCanManage = computed(() =>
161
+ currentProject.value ? canManageProject(currentProject.value.id) : false,
162
+ )
163
+
164
+ const search = ref('')
165
+ const filterLangs = ref<string[]>([])
166
+ const filterStatus = ref((route.query.status as string) || 'all')
167
+ const page = ref(1)
168
+ const limit = 25
169
+ const showAddKey = ref(false)
170
+ const newKey = ref({ key: '', description: '' })
171
+
172
+ const langOptions = computed(() =>
173
+ languages.value.map((l: any) => ({ label: findLanguage(l.code)?.nativeName || l.name, value: l.code }))
174
+ )
175
+
176
+ const visibleLanguages = computed(() =>
177
+ filterLangs.value.length
178
+ ? languages.value.filter((l: any) => filterLangs.value.includes(l.code))
179
+ : languages.value
180
+ )
181
+
182
+ const statusFilters = computed(() => [
183
+ { value: 'all', label: t('status.all', 'All'), dot: 'bg-gray-300', activeBg: 'bg-gray-100 dark:bg-gray-700', activeText: 'text-gray-700 dark:text-gray-200' },
184
+ { value: 'missing', label: t('status.missing', 'Missing'), dot: 'bg-red-400', activeBg: 'bg-red-50 dark:bg-red-900/20', activeText: 'text-red-700 dark:text-red-300' },
185
+ { value: TRANSLATION_STATUS.DRAFT, label: t('status.draft', 'Draft'), dot: 'bg-yellow-400', activeBg: 'bg-yellow-50 dark:bg-yellow-900/20', activeText: 'text-yellow-700 dark:text-yellow-300' },
186
+ { value: TRANSLATION_STATUS.REVIEWED, label: t('status.reviewed', 'Reviewed'), dot: 'bg-blue-400', activeBg: 'bg-blue-50 dark:bg-blue-900/20', activeText: 'text-blue-700 dark:text-blue-300' },
187
+ { value: TRANSLATION_STATUS.APPROVED, label: t('status.approved', 'Approved'), dot: 'bg-green-500', activeBg: 'bg-green-50 dark:bg-green-900/20', activeText: 'text-green-700 dark:text-green-300' },
188
+ { value: 'unused', label: t('status.unused', 'Unused'), dot: 'bg-orange-400', activeBg: 'bg-orange-50 dark:bg-orange-900/20', activeText: 'text-orange-700 dark:text-orange-300' },
189
+ ])
190
+
191
+ const gridStyle = computed(() => ({
192
+ gridTemplateColumns: `minmax(220px, 1.5fr) ${visibleLanguages.value.map(() => 'minmax(160px, 1fr)').join(' ')} 48px`,
193
+ }))
194
+
195
+ const queryParams = computed(() => ({
196
+ project_id: currentProject.value?.id,
197
+ search: search.value || undefined,
198
+ lang: filterLangs.value.length === 1 ? filterLangs.value[0] : undefined,
199
+ status: filterStatus.value !== 'all' ? filterStatus.value : undefined,
200
+ page: page.value,
201
+ limit,
202
+ }))
203
+
204
+ const {
205
+ data,
206
+ pending,
207
+ refresh,
208
+ addingKey,
209
+ createKey,
210
+ scanning,
211
+ scan,
212
+ syncing,
213
+ sync,
214
+ batchTranslating,
215
+ batchTranslate: doBatchTranslate,
216
+ } = useKeys({ queryParams })
217
+
218
+ let searchTimeout: ReturnType<typeof setTimeout>
219
+ function debouncedRefresh() {
220
+ clearTimeout(searchTimeout)
221
+ searchTimeout = setTimeout(() => {
222
+ page.value = 1
223
+ refresh()
224
+ }, 300)
225
+ }
226
+
227
+ async function addKey() {
228
+ if (!newKey.value.key.trim() || !currentProject.value) return
229
+ const ok = await createKey(currentProject.value.id, newKey.value.key, newKey.value.description)
230
+ if (ok) {
231
+ showAddKey.value = false
232
+ newKey.value = { key: '', description: '' }
233
+ }
234
+ }
235
+
236
+ async function scanProject() {
237
+ if (!currentProject.value) return
238
+ await scan(currentProject.value.id)
239
+ }
240
+
241
+ async function syncFiles() {
242
+ if (!currentProject.value) return
243
+ await sync(currentProject.value.id)
244
+ }
245
+
246
+ async function batchTranslate() {
247
+ if (!currentProject.value) return
248
+ await doBatchTranslate(currentProject.value.id, filterLangs.value[0])
249
+ }
250
+ </script>
@@ -0,0 +1,276 @@
1
+ <template>
2
+ <div class="p-6">
3
+ <div class="flex items-center justify-between mb-6">
4
+ <div>
5
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('users.title', 'Users') }}</h1>
6
+ <p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
7
+ {{ t('users.project_members', 'Members of project') }} <strong>{{ currentProject?.name }}</strong>
8
+ </p>
9
+ </div>
10
+ <UButton icon="i-heroicons-plus" @click="openAdd">{{ t('users.add', 'Add a user') }}</UButton>
11
+ </div>
12
+
13
+ <!-- Users table -->
14
+ <UCard>
15
+ <div v-if="pending" class="space-y-3">
16
+ <USkeleton v-for="i in 4" :key="i" class="h-12" />
17
+ </div>
18
+ <div v-else-if="!users.length" class="text-center py-12">
19
+ <UIcon name="i-heroicons-users" class="text-4xl text-gray-300 mb-2" />
20
+ <p class="text-gray-400">{{ t('users.none_in_project', 'No users in this project') }}</p>
21
+ </div>
22
+ <div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
23
+ <div
24
+ v-for="user in users"
25
+ :key="user.id"
26
+ class="flex items-center justify-between py-3 gap-4"
27
+ >
28
+ <div class="flex items-center gap-3 min-w-0">
29
+ <div class="w-9 h-9 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
30
+ <span class="text-sm font-bold text-primary-600 dark:text-primary-400">
31
+ {{ user.name.charAt(0).toUpperCase() }}
32
+ </span>
33
+ </div>
34
+ <div class="min-w-0">
35
+ <div class="flex items-center gap-2">
36
+ <p class="font-medium text-gray-900 dark:text-white text-sm truncate">{{ user.name }}</p>
37
+ <UBadge v-if="!user.is_active" size="xs" color="neutral" variant="outline">{{ t('users.inactive', 'Inactive') }}</UBadge>
38
+ </div>
39
+ <p class="text-xs text-gray-400 truncate">{{ user.email }}</p>
40
+ </div>
41
+ </div>
42
+
43
+ <!-- Role badge -->
44
+ <div class="flex-1 flex justify-center">
45
+ <UBadge v-if="user.role" size="xs" :color="roleColor(user.role)" variant="soft">
46
+ {{ roleLabel(user.role) }}
47
+ </UBadge>
48
+ <span v-else class="text-xs text-gray-400 italic">{{ t('users.no_role', 'No role') }}</span>
49
+ </div>
50
+
51
+ <div class="flex items-center gap-2 shrink-0">
52
+ <span class="text-xs text-gray-400">
53
+ {{ user.last_login_at ? formatRelative(user.last_login_at) : t('users.never_connected', 'Never logged in') }}
54
+ </span>
55
+ <UDropdownMenu :items="userActions(user)">
56
+ <UButton icon="i-heroicons-ellipsis-vertical" color="neutral" variant="ghost" size="xs" />
57
+ </UDropdownMenu>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </UCard>
62
+
63
+ <!-- Add user modal -->
64
+ <UModal v-model:open="showModal" :title="t('users.add_user_title', 'Add a user')">
65
+ <template #body>
66
+ <div class="space-y-4">
67
+ <div class="grid grid-cols-2 gap-4">
68
+ <UFormField :label="t('users.full_name', 'Full name')" required>
69
+ <UInput v-model="form.name" placeholder="Marie Dupont" class="w-full" />
70
+ </UFormField>
71
+ <UFormField :label="t('login.email', 'Email')" required>
72
+ <UInput v-model="form.email" type="email" placeholder="marie@example.com" class="w-full" />
73
+ </UFormField>
74
+ </div>
75
+
76
+ <UFormField :label="t('users.role_label', 'Role')" required>
77
+ <USelect v-model="form.role" :items="roleOptions" class="w-full" />
78
+ </UFormField>
79
+
80
+ <UFormField :label="t('users.project_label', 'Project')">
81
+ <UInput :model-value="currentProject?.name" class="w-full" disabled />
82
+ </UFormField>
83
+
84
+ <div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
85
+ <p class="text-xs text-blue-600 dark:text-blue-400">
86
+ <UIcon name="i-heroicons-information-circle" class="inline mr-1" />
87
+ {{ t('users.temp_password_info', 'A temporary password will be generated and shown below.') }}
88
+ </p>
89
+ </div>
90
+
91
+ <div v-if="createdTempPassword" class="bg-green-50 dark:bg-green-900/20 rounded-lg p-3">
92
+ <p class="text-xs text-green-700 dark:text-green-300 font-medium mb-1">
93
+ <UIcon name="i-heroicons-key" class="inline mr-1" />
94
+ {{ t('users.temp_password_label', 'Temporary password (copy it now):') }}
95
+ </p>
96
+ <div class="flex items-center gap-2">
97
+ <code class="text-sm font-mono text-green-800 dark:text-green-200 flex-1">{{ createdTempPassword }}</code>
98
+ <UButton size="xs" icon="i-heroicons-clipboard" color="neutral" variant="ghost" @click="copyTemp" />
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </template>
103
+ <template #footer>
104
+ <div class="flex justify-end gap-3">
105
+ <UButton color="neutral" variant="ghost" @click="closeModal">
106
+ {{ createdTempPassword ? t('common.close', 'Close') : t('common.cancel', 'Cancel') }}
107
+ </UButton>
108
+ <UButton v-if="!createdTempPassword" :loading="saving" @click="saveUser">{{ t('common.create', 'Create') }}</UButton>
109
+ </div>
110
+ </template>
111
+ </UModal>
112
+
113
+ <!-- Role modal -->
114
+ <UModal v-model:open="showRoleModal" :title="t('users.edit_role_title', 'Edit role')">
115
+ <template #body>
116
+ <div class="space-y-4">
117
+ <div class="flex items-center gap-3 pb-4 border-b border-gray-100 dark:border-gray-800">
118
+ <div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
119
+ <span class="font-bold text-primary-600 dark:text-primary-400">
120
+ {{ roleModalUser?.name.charAt(0).toUpperCase() }}
121
+ </span>
122
+ </div>
123
+ <div>
124
+ <p class="font-semibold text-gray-900 dark:text-white text-sm">{{ roleModalUser?.name }}</p>
125
+ <p class="text-xs text-gray-400">{{ roleModalUser?.email }}</p>
126
+ </div>
127
+ </div>
128
+ <UFormField :label="`${t('users.role_in_project', 'Role in')} ${currentProject?.name}`">
129
+ <USelect v-model="roleModalValue" :items="roleOptions" class="w-full" />
130
+ </UFormField>
131
+ </div>
132
+ </template>
133
+ <template #footer>
134
+ <div class="flex justify-end gap-3">
135
+ <UButton color="neutral" variant="ghost" @click="showRoleModal = false">{{ t('common.cancel', 'Cancel') }}</UButton>
136
+ <UButton :loading="rolesSaving" @click="saveRole">{{ t('common.save', 'Save') }}</UButton>
137
+ </div>
138
+ </template>
139
+ </UModal>
140
+
141
+ <!-- Delete confirm -->
142
+ <UModal v-model:open="showDeleteConfirm" :title="t('users.remove_user_title', 'Remove user')">
143
+ <template #body>
144
+ <p class="text-gray-600 dark:text-gray-400">
145
+ {{ t('users.remove_confirm', 'Remove') }} <strong>{{ deletingUser?.name }}</strong> {{ t('users.remove_from_project', 'from project') }} <strong>{{ currentProject?.name }}</strong>?
146
+ {{ t('users.remove_account_kept', 'Their account will not be deleted.') }}
147
+ </p>
148
+ </template>
149
+ <template #footer>
150
+ <div class="flex justify-end gap-3">
151
+ <UButton color="neutral" variant="ghost" @click="showDeleteConfirm = false">{{ t('common.cancel', 'Cancel') }}</UButton>
152
+ <UButton color="error" :loading="deleting" @click="deleteUser">{{ t('users.remove_btn', 'Remove') }}</UButton>
153
+ </div>
154
+ </template>
155
+ </UModal>
156
+ </div>
157
+ </template>
158
+
159
+ <script setup lang="ts">
160
+ const toast = useToast()
161
+ const { currentUser } = useAuth()
162
+ const { currentProject } = useProject()
163
+ const { t } = useT()
164
+
165
+ // Guard: requires project context
166
+ watch(currentProject, (p) => { if (!p) navigateTo('/projects') }, { immediate: true })
167
+
168
+ const showModal = ref(false)
169
+ const showDeleteConfirm = ref(false)
170
+ const showRoleModal = ref(false)
171
+ const deletingUser = ref<any>(null)
172
+ const createdTempPassword = ref('')
173
+ const roleModalUser = ref<any>(null)
174
+ const roleModalValue = ref('translator')
175
+
176
+ const form = ref({ name: '', email: '', role: 'translator' })
177
+
178
+ const roleOptions = computed(() => [
179
+ { label: t('users.role_translator', 'Translator'), value: 'translator' },
180
+ { label: t('users.role_moderator', 'Moderator'), value: 'moderator' },
181
+ { label: t('users.role_admin', 'Admin'), value: 'admin' },
182
+ ])
183
+
184
+ const { users, pending, saving, createUser, rolesSaving, updateRoles, toggleActive, deleting, deleteUser: doDeleteUser } = useUsers('project')
185
+
186
+ function roleLabel(role: string) {
187
+ return { translator: t('users.role_translator', 'Translator'), moderator: t('users.role_moderator', 'Moderator'), admin: t('users.role_admin', 'Admin') }[role] || role
188
+ }
189
+
190
+ function roleColor(role: string) {
191
+ return { translator: 'primary', moderator: 'warning', admin: 'success' }[role] || 'neutral'
192
+ }
193
+
194
+ function formatRelative(date: string) {
195
+ const diff = Date.now() - new Date(date).getTime()
196
+ const min = Math.floor(diff / 60000)
197
+ if (min < 1) return t('common.just_now', 'just now')
198
+ if (min < 60) return `${t('common.ago', 'ago')} ${min}min`
199
+ const h = Math.floor(min / 60)
200
+ if (h < 24) return `${t('common.ago', 'ago')} ${h}h`
201
+ return `${t('common.ago', 'ago')} ${Math.floor(h / 24)}d`
202
+ }
203
+
204
+ function userActions(user: any) {
205
+ const isSelf = user.id === currentUser.value?.id
206
+ return [
207
+ [
208
+ ...(!isSelf ? [{
209
+ label: t('users.edit_role', 'Edit role'),
210
+ icon: 'i-heroicons-shield-check',
211
+ onSelect: () => openRoleModal(user),
212
+ }] : []),
213
+ {
214
+ label: user.is_active ? t('users.deactivate', 'Deactivate') : t('users.reactivate', 'Reactivate'),
215
+ icon: user.is_active ? 'i-heroicons-pause' : 'i-heroicons-play',
216
+ onSelect: () => toggleActive(user),
217
+ },
218
+ ],
219
+ [
220
+ {
221
+ label: t('users.remove_from_project_action', 'Remove from project'),
222
+ icon: 'i-heroicons-user-minus',
223
+ color: 'error' as const,
224
+ onSelect: () => { deletingUser.value = user; showDeleteConfirm.value = true },
225
+ },
226
+ ],
227
+ ]
228
+ }
229
+
230
+ function openAdd() {
231
+ createdTempPassword.value = ''
232
+ form.value = { name: '', email: '', role: 'translator' }
233
+ showModal.value = true
234
+ }
235
+
236
+ function openRoleModal(user: any) {
237
+ roleModalUser.value = user
238
+ roleModalValue.value = user.role || 'translator'
239
+ showRoleModal.value = true
240
+ }
241
+
242
+ function closeModal() {
243
+ showModal.value = false
244
+ createdTempPassword.value = ''
245
+ }
246
+
247
+ async function saveUser() {
248
+ if (!form.value.name || !form.value.email || !currentProject.value) return
249
+ const tempPassword = await createUser({
250
+ ...form.value,
251
+ project_id: currentProject.value.id,
252
+ project_ids: [currentProject.value.id],
253
+ global_access: false,
254
+ })
255
+ if (tempPassword) createdTempPassword.value = tempPassword
256
+ }
257
+
258
+ async function saveRole() {
259
+ if (!roleModalUser.value || !currentProject.value) return
260
+ const ok = await updateRoles(roleModalUser.value.id, [
261
+ { project_id: currentProject.value.id, role: roleModalValue.value },
262
+ ])
263
+ if (ok) showRoleModal.value = false
264
+ }
265
+
266
+ async function deleteUser() {
267
+ if (!deletingUser.value) return
268
+ const ok = await doDeleteUser(deletingUser.value.id)
269
+ if (ok) showDeleteConfirm.value = false
270
+ }
271
+
272
+ async function copyTemp() {
273
+ await navigator.clipboard.writeText(createdTempPassword.value)
274
+ toast.add({ title: t('common.copied', 'Copied!'), color: 'success' })
275
+ }
276
+ </script>