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,421 @@
1
+ <template>
2
+ <div class="p-4 lg:p-6 max-w-5xl mx-auto space-y-6">
3
+
4
+ <!-- Loading -->
5
+ <div v-if="pending" class="space-y-4">
6
+ <USkeleton class="h-24" />
7
+ <div class="grid grid-cols-3 gap-4">
8
+ <USkeleton v-for="i in 3" :key="i" class="h-20" />
9
+ </div>
10
+ </div>
11
+
12
+ <template v-else-if="profile">
13
+ <!-- Header -->
14
+ <div class="flex items-center gap-4">
15
+ <div class="w-16 h-16 rounded-2xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
16
+ <span class="text-2xl font-bold text-primary-600 dark:text-primary-400">
17
+ {{ profile.user.name?.charAt(0)?.toUpperCase() }}
18
+ </span>
19
+ </div>
20
+ <div class="flex-1 min-w-0">
21
+ <div class="flex items-center gap-2 flex-wrap">
22
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ profile.user.name }}</h1>
23
+ <UBadge v-if="profile.user.is_super_admin" color="warning" variant="soft">
24
+ <UIcon name="i-heroicons-star" class="mr-1" />
25
+ Super Admin
26
+ </UBadge>
27
+ </div>
28
+ <p class="text-sm text-gray-400 mt-0.5">{{ profile.user.email }}</p>
29
+ <div class="flex items-center gap-3 mt-1 text-xs text-gray-400 flex-wrap">
30
+ <span class="flex items-center gap-1">
31
+ <UIcon name="i-heroicons-calendar" />
32
+ {{ t('profile.member_since', 'Member since') }} {{ formatDate(profile.user.created_at) }}
33
+ </span>
34
+ <span v-if="profile.user.last_login_at" class="flex items-center gap-1">
35
+ <UIcon name="i-heroicons-clock" />
36
+ {{ t('profile.last_login', 'Last login') }} {{ formatDate(profile.user.last_login_at) }}
37
+ </span>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="flex items-center gap-2 shrink-0">
42
+ <!-- Edit own account -->
43
+ <UButton v-if="isSelf" color="neutral" variant="outline" icon="i-heroicons-pencil" @click="openEdit">
44
+ {{ t('profile.edit_account', 'Edit my account') }}
45
+ </UButton>
46
+ <!-- Manage roles (authorized viewers only) -->
47
+ <UButton v-if="canManageRoles" color="neutral" variant="outline" icon="i-heroicons-shield-check" @click="openRoles">
48
+ {{ t('users.manage_access', 'Manage access') }}
49
+ </UButton>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- Stats -->
54
+ <div class="grid grid-cols-3 gap-4">
55
+ <UCard v-for="stat in statCards" :key="stat.label">
56
+ <div class="flex items-center gap-3">
57
+ <div class="p-2 rounded-lg" :class="stat.bg">
58
+ <UIcon :name="stat.icon" class="text-xl" :class="stat.color" />
59
+ </div>
60
+ <div>
61
+ <p class="text-xs text-gray-500 dark:text-gray-400">{{ stat.label }}</p>
62
+ <p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stat.value }}</p>
63
+ </div>
64
+ </div>
65
+ </UCard>
66
+ </div>
67
+
68
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
69
+
70
+ <!-- Left column -->
71
+ <div class="space-y-4">
72
+
73
+ <!-- Roles & Projects -->
74
+ <UCard>
75
+ <template #header>
76
+ <div class="flex items-center gap-2">
77
+ <UIcon name="i-heroicons-briefcase" class="text-gray-400" />
78
+ <p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">{{ t('profile.projects_roles', 'Projects & Roles') }}</p>
79
+ </div>
80
+ </template>
81
+ <div v-if="!profile.roles.length" class="text-sm text-gray-400 italic">{{ t('profile.no_roles', 'No role assigned') }}</div>
82
+ <div v-else class="space-y-2">
83
+ <div
84
+ v-for="role in profile.roles"
85
+ :key="`${role.project_id}-${role.role}`"
86
+ class="flex items-center justify-between gap-2"
87
+ >
88
+ <div class="flex items-center gap-2 min-w-0">
89
+ <span
90
+ v-if="role.project_id"
91
+ class="w-2 h-2 rounded-full shrink-0"
92
+ :class="`bg-${role.project_color || 'primary'}-500`"
93
+ />
94
+ <UIcon v-else name="i-heroicons-globe-alt" class="text-gray-400 text-xs shrink-0" />
95
+ <span class="text-sm text-gray-700 dark:text-gray-300 truncate">
96
+ {{ role.project_name ?? t('users.all_projects', 'All projects') }}
97
+ </span>
98
+ </div>
99
+ <UBadge :color="roleColor(role.role)" variant="soft" size="xs" class="shrink-0">
100
+ {{ roleLabel(role.role) }}
101
+ </UBadge>
102
+ </div>
103
+ </div>
104
+ </UCard>
105
+
106
+ <!-- Languages -->
107
+ <UCard>
108
+ <template #header>
109
+ <div class="flex items-center gap-2">
110
+ <UIcon name="i-heroicons-language" class="text-gray-400" />
111
+ <p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">{{ t('nav.languages', 'Languages') }}</p>
112
+ </div>
113
+ </template>
114
+ <div v-if="!profile.languages.length" class="text-sm text-gray-400 italic">{{ t('profile.no_languages', 'No language available') }}</div>
115
+ <div v-else>
116
+ <div v-for="(langs, projectName) in languagesByProject" :key="projectName" class="mb-3 last:mb-0">
117
+ <p class="text-xs text-gray-400 mb-1.5 font-medium">{{ projectName }}</p>
118
+ <div class="flex flex-wrap gap-1.5">
119
+ <UBadge
120
+ v-for="lang in langs"
121
+ :key="lang.code"
122
+ color="neutral"
123
+ variant="soft"
124
+ size="xs"
125
+ class="font-mono"
126
+ >
127
+ {{ nativeName(lang.code) || lang.name }}
128
+ <span class="ml-1 opacity-50">{{ lang.code }}</span>
129
+ </UBadge>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </UCard>
134
+ </div>
135
+
136
+ <!-- Right column: Activity -->
137
+ <div class="lg:col-span-2">
138
+ <UCard>
139
+ <template #header>
140
+ <div class="flex items-center gap-2">
141
+ <UIcon name="i-heroicons-clock" class="text-gray-400" />
142
+ <p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">{{ t('dashboard.recent_activity', 'Recent activity') }}</p>
143
+ </div>
144
+ </template>
145
+
146
+ <div v-if="!profile.recentTranslations.length" class="text-center py-10">
147
+ <UIcon name="i-heroicons-pencil-square" class="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
148
+ <p class="text-sm text-gray-400">{{ t('profile.no_translations', 'No translations yet') }}</p>
149
+ </div>
150
+
151
+ <div v-else class="space-y-1">
152
+ <NuxtLink
153
+ v-for="tr in profile.recentTranslations"
154
+ :key="tr.id"
155
+ :to="tr.project_id ? `/projects/${tr.project_id}/translations/${tr.key_id}` : `/projects`"
156
+ class="flex items-start gap-3 px-2 py-2.5 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors group"
157
+ >
158
+ <span class="font-mono text-xs font-bold bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-gray-500 dark:text-gray-400 uppercase shrink-0 mt-0.5">
159
+ {{ tr.language_code }}
160
+ </span>
161
+ <div class="flex-1 min-w-0">
162
+ <div class="flex items-center gap-2 flex-wrap">
163
+ <span class="text-sm font-mono font-medium text-gray-700 dark:text-gray-300 truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
164
+ {{ tr.key }}
165
+ </span>
166
+ <span class="w-1.5 h-1.5 rounded-full shrink-0" :class="`bg-${tr.project_color || 'primary'}-500`" />
167
+ <span class="text-xs text-gray-400 shrink-0">{{ tr.project_name }}</span>
168
+ </div>
169
+ <div class="flex items-center gap-2 mt-0.5">
170
+ <span v-if="tr.old_value" class="text-xs text-red-400 line-through truncate max-w-[120px]">{{ tr.old_value }}</span>
171
+ <UIcon v-if="tr.old_value" name="i-heroicons-arrow-right" class="text-gray-300 text-xs shrink-0" />
172
+ <span class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-xs">{{ tr.new_value }}</span>
173
+ </div>
174
+ </div>
175
+ <span class="text-xs text-gray-400 shrink-0 mt-0.5">{{ timeAgo(tr.changed_at) }}</span>
176
+ </NuxtLink>
177
+ </div>
178
+ </UCard>
179
+ </div>
180
+ </div>
181
+ </template>
182
+
183
+ <!-- Error state -->
184
+ <div v-else class="text-center py-20">
185
+ <UIcon name="i-heroicons-exclamation-circle" class="text-5xl text-gray-300 mb-3" />
186
+ <p class="text-gray-400">{{ t('profile.not_found', 'Profile not found or access denied.') }}</p>
187
+ <UButton to="/users" class="mt-4" variant="outline" color="neutral">{{ t('profile.back', 'Back') }}</UButton>
188
+ </div>
189
+
190
+ <!-- Edit account modal (self only) -->
191
+ <UModal v-model:open="showEdit" :title="t('profile.edit_modal_title', 'Edit my account')">
192
+ <template #body>
193
+ <div class="space-y-4">
194
+ <UFormField :label="t('profile.name_label', 'Name')" required>
195
+ <UInput v-model="editForm.name" :placeholder="t('profile.name_placeholder', 'Your name')" class="w-full" />
196
+ </UFormField>
197
+ <UFormField :label="t('login.email', 'Email')" required>
198
+ <UInput v-model="editForm.email" type="email" placeholder="you@example.com" class="w-full" />
199
+ </UFormField>
200
+ <p v-if="editError" class="text-sm text-red-500">{{ editError }}</p>
201
+ </div>
202
+ </template>
203
+ <template #footer>
204
+ <div class="flex justify-end gap-3">
205
+ <UButton color="neutral" variant="ghost" @click="showEdit = false">{{ t('common.cancel', 'Cancel') }}</UButton>
206
+ <UButton :loading="editSaving" @click="saveEdit">{{ t('common.save', 'Save') }}</UButton>
207
+ </div>
208
+ </template>
209
+ </UModal>
210
+
211
+ <!-- Roles modal (authorized viewers) -->
212
+ <UModal v-model:open="showRolesModal" :title="t('users.manage_access_title', 'Manage access')">
213
+ <template #body>
214
+ <div class="space-y-4">
215
+ <div class="flex items-center gap-3 pb-4 border-b border-gray-100 dark:border-gray-800">
216
+ <div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
217
+ <span class="font-bold text-primary-600 dark:text-primary-400">
218
+ {{ profile?.user.name?.charAt(0)?.toUpperCase() }}
219
+ </span>
220
+ </div>
221
+ <div>
222
+ <p class="font-semibold text-gray-900 dark:text-white text-sm">{{ profile?.user.name }}</p>
223
+ <p class="text-xs text-gray-400">{{ profile?.user.email }}</p>
224
+ </div>
225
+ </div>
226
+
227
+ <div class="space-y-2">
228
+ <div
229
+ v-for="item in rolesForm"
230
+ :key="item.project_id ?? 'global'"
231
+ class="flex items-center justify-between gap-3"
232
+ >
233
+ <div class="flex items-center gap-2 min-w-0 flex-1">
234
+ <UIcon v-if="item.project_id === null" name="i-heroicons-globe-alt" class="text-gray-400 shrink-0" />
235
+ <span v-else class="w-2 h-2 rounded-full shrink-0" :class="`bg-${item.project_color || 'primary'}-500`" />
236
+ <span class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ item.project_name }}</span>
237
+ </div>
238
+ <USelect v-model="item.role" :items="accessOptions(item.project_id)" class="w-44 shrink-0" />
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </template>
243
+ <template #footer>
244
+ <div class="flex justify-end gap-3">
245
+ <UButton color="neutral" variant="ghost" @click="showRolesModal = false">{{ t('common.cancel', 'Cancel') }}</UButton>
246
+ <UButton :loading="rolesSaving" @click="doSaveRoles">{{ t('common.save', 'Save') }}</UButton>
247
+ </div>
248
+ </template>
249
+ </UModal>
250
+ </div>
251
+ </template>
252
+
253
+ <script lang="ts" setup>
254
+ const route = useRoute()
255
+ const { findLanguage } = useLanguages()
256
+ const { currentUser, canManageUsers, getRoleForProject } = useAuth()
257
+ const { projects } = useProject()
258
+ const { t } = useT()
259
+
260
+ const userId = computed(() => Number(route.params.id))
261
+ const { profile, pending, refresh, editSaving: selfEditSaving, editError, updateProfile, rolesSaving, saveRoles } = useProfile(userId)
262
+
263
+ const isSelf = computed(() => currentUser.value?.id === userId.value)
264
+
265
+ // Viewer can manage roles if super admin OR admin in at least one project the target belongs to
266
+ const canManageRoles = computed(() => {
267
+ if (!currentUser.value || !profile.value) return false
268
+ if (currentUser.value.is_super_admin) return true
269
+ const targetProjectIds = new Set(
270
+ profile.value.roles.filter(r => r.project_id !== null).map(r => r.project_id),
271
+ )
272
+ return [...targetProjectIds].some(pid => getRoleForProject(pid as number) === 'admin')
273
+ })
274
+
275
+ // ── Edit own account ───────────────────────────────────────────────────────────
276
+ const showEdit = ref(false)
277
+ const editForm = ref({ name: '', email: '' })
278
+ const editSaving = selfEditSaving
279
+
280
+ function openEdit() {
281
+ editForm.value.name = profile.value?.user.name ?? ''
282
+ editForm.value.email = profile.value?.user.email ?? ''
283
+ showEdit.value = true
284
+ }
285
+
286
+ async function saveEdit() {
287
+ if (!editForm.value.name.trim() || !editForm.value.email.trim()) {
288
+ editError.value = t('profile.name_email_required', 'Name and email are required.')
289
+ return
290
+ }
291
+ const ok = await updateProfile(editForm.value.name, editForm.value.email)
292
+ if (ok) {
293
+ showEdit.value = false
294
+ await refresh()
295
+ }
296
+ }
297
+
298
+ // ── Roles management ──────────────────────────────────────────────────────────
299
+ const showRolesModal = ref(false)
300
+ const rolesForm = ref<Array<{
301
+ project_id: number | null
302
+ project_name: string
303
+ project_color: string | null
304
+ role: string
305
+ }>>([])
306
+
307
+ function accessOptions(projectId: number | null) {
308
+ const base = [
309
+ { label: t('users.no_access', 'No access'), value: 'none' },
310
+ { label: t('users.role_translator', 'Translator'), value: 'translator' },
311
+ { label: t('users.role_moderator', 'Moderator'), value: 'moderator' },
312
+ { label: t('users.role_admin', 'Admin'), value: 'admin' },
313
+ ]
314
+ // Non-super-admin can only set roles on projects where they are admin
315
+ if (!currentUser.value?.is_super_admin && projectId !== null) {
316
+ const myRole = getRoleForProject(projectId)
317
+ if (myRole !== 'admin') return base.map(o => ({ ...o, disabled: true }))
318
+ }
319
+ return base
320
+ }
321
+
322
+ function openRoles() {
323
+ const targetRoles = profile.value?.roles ?? []
324
+
325
+ // Super admin sees all projects + global; others see only their admin projects
326
+ const visibleProjects = currentUser.value?.is_super_admin
327
+ ? projects.value
328
+ : projects.value.filter(p => getRoleForProject(p.id) === 'admin')
329
+
330
+ rolesForm.value = [
331
+ // Global access row (super admin only)
332
+ ...(currentUser.value?.is_super_admin ? [{
333
+ project_id: null,
334
+ project_name: t('users.global_access_all', 'Global access (all projects)'),
335
+ project_color: null,
336
+ role: targetRoles.find(r => r.project_id === null)?.role ?? 'none',
337
+ }] : []),
338
+ ...visibleProjects.map((p: any) => ({
339
+ project_id: p.id,
340
+ project_name: p.name,
341
+ project_color: p.color ?? null,
342
+ role: targetRoles.find(r => r.project_id === p.id)?.role ?? 'none',
343
+ })),
344
+ ]
345
+ showRolesModal.value = true
346
+ }
347
+
348
+ async function doSaveRoles() {
349
+ const roles = rolesForm.value.map(({ project_id, role }) => ({
350
+ project_id,
351
+ role: role === 'none' ? null : role,
352
+ }))
353
+ const ok = await saveRoles(roles)
354
+ if (ok) showRolesModal.value = false
355
+ }
356
+
357
+ // ── Stats ──────────────────────────────────────────────────────────────────────
358
+ const statCards = computed(() => [
359
+ {
360
+ label: t('profile.total_translations', 'Total translations'),
361
+ value: profile.value?.stats.total ?? 0,
362
+ icon: 'i-heroicons-pencil-square',
363
+ color: 'text-primary-600',
364
+ bg: 'bg-primary-50 dark:bg-primary-900/20',
365
+ },
366
+ {
367
+ label: t('profile.this_month', 'This month'),
368
+ value: profile.value?.stats.thisMonth ?? 0,
369
+ icon: 'i-heroicons-calendar-days',
370
+ color: 'text-blue-600',
371
+ bg: 'bg-blue-50 dark:bg-blue-900/20',
372
+ },
373
+ {
374
+ label: t('profile.this_week', 'This week'),
375
+ value: profile.value?.stats.thisWeek ?? 0,
376
+ icon: 'i-heroicons-bolt',
377
+ color: 'text-green-600',
378
+ bg: 'bg-green-50 dark:bg-green-900/20',
379
+ },
380
+ ])
381
+
382
+ // ── Languages grouped by project ───────────────────────────────────────────────
383
+ const languagesByProject = computed(() => {
384
+ const groups: Record<string, any[]> = {}
385
+ for (const lang of profile.value?.languages ?? []) {
386
+ const key = lang.project_name ?? t('profile.general', 'General')
387
+ if (!groups[key]) groups[key] = []
388
+ groups[key].push(lang)
389
+ }
390
+ return groups
391
+ })
392
+
393
+ function nativeName(code: string) {
394
+ return findLanguage(code)?.nativeName
395
+ }
396
+
397
+ // ── Role helpers ───────────────────────────────────────────────────────────────
398
+ const ROLE_COLORS: Record<string, string> = { admin: 'error', moderator: 'warning', translator: 'info' }
399
+ function roleLabel(role: string) {
400
+ return { admin: t('users.role_admin', 'Admin'), moderator: t('users.role_moderator', 'Moderator'), translator: t('users.role_translator', 'Translator') }[role] ?? role
401
+ }
402
+ function roleColor(role: string) { return ROLE_COLORS[role] ?? 'neutral' }
403
+
404
+ // ── Date helpers ───────────────────────────────────────────────────────────────
405
+ function formatDate(date: string | null | undefined) {
406
+ if (!date) return '—'
407
+ return new Intl.DateTimeFormat('en', { dateStyle: 'long' }).format(new Date(date))
408
+ }
409
+
410
+ function timeAgo(date: string) {
411
+ const diff = Date.now() - new Date(date).getTime()
412
+ const mins = Math.floor(diff / 60000)
413
+ if (mins < 1) return t('common.just_now', 'just now')
414
+ if (mins < 60) return `${t('common.ago', 'ago')} ${mins}min`
415
+ const hours = Math.floor(mins / 60)
416
+ if (hours < 24) return `${t('common.ago', 'ago')} ${hours}h`
417
+ const days = Math.floor(hours / 24)
418
+ if (days < 7) return `${t('common.ago', 'ago')} ${days}d`
419
+ return formatDate(date)
420
+ }
421
+ </script>