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.
- package/LICENSE +21 -0
- package/README.md +715 -0
- package/app.vue +8 -0
- package/assets/css/main.css +21 -0
- package/assets/locales/en.json +380 -0
- package/bin/cli.mjs +279 -0
- package/components/LinkedKeyPicker.vue +135 -0
- package/components/PathPicker.vue +153 -0
- package/components/PluralEditor.vue +295 -0
- package/components/ScanModal.vue +153 -0
- package/components/TranslationHistoryModal.vue +66 -0
- package/components/TranslationRow.vue +541 -0
- package/components/dashboard/WidgetConfigModal.vue +121 -0
- package/components/dashboard/WidgetGrid.vue +190 -0
- package/components/dashboard/WidgetPicker.vue +75 -0
- package/components/dashboard/widgets/ActivityWidget.vue +109 -0
- package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
- package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
- package/components/dashboard/widgets/ReviewWidget.vue +150 -0
- package/components/dashboard/widgets/StatWidget.vue +133 -0
- package/composables/useAuth.ts +72 -0
- package/composables/useConfig.ts +14 -0
- package/composables/useDashboard.ts +89 -0
- package/composables/useFormats.ts +100 -0
- package/composables/useKeys.ts +231 -0
- package/composables/useLanguages.ts +221 -0
- package/composables/useProfile.ts +76 -0
- package/composables/useProject.ts +180 -0
- package/composables/useReview.ts +94 -0
- package/composables/useSettings.ts +30 -0
- package/composables/useStats.ts +16 -0
- package/composables/useT.ts +38 -0
- package/composables/useUsers.ts +101 -0
- package/composables/useWidgetData.ts +50 -0
- package/consts/commons.const.ts +6 -0
- package/consts/dashboard.const.ts +94 -0
- package/consts/languages.const.ts +223 -0
- package/enums/commons.enum.ts +7 -0
- package/i18n-dashboard.config.example.js +40 -0
- package/interfaces/commons.interface.ts +23 -0
- package/interfaces/job.interface.ts +10 -0
- package/interfaces/key.interface.ts +39 -0
- package/interfaces/languages.interface.ts +23 -0
- package/interfaces/project.interface.ts +9 -0
- package/interfaces/scan.interface.ts +12 -0
- package/interfaces/settings.interface.ts +4 -0
- package/interfaces/stat.interface.ts +30 -0
- package/interfaces/translation.interface.ts +11 -0
- package/interfaces/user.interface.ts +24 -0
- package/layouts/auth.vue +5 -0
- package/layouts/default.vue +327 -0
- package/middleware/auth.global.ts +26 -0
- package/nuxt.config.ts +66 -0
- package/package.json +89 -0
- package/pages/index.vue +5 -0
- package/pages/login.vue +74 -0
- package/pages/onboarding.vue +563 -0
- package/pages/projects/[id]/formats/datetime.vue +240 -0
- package/pages/projects/[id]/formats/modifiers.vue +194 -0
- package/pages/projects/[id]/formats/number.vue +250 -0
- package/pages/projects/[id]/index.vue +182 -0
- package/pages/projects/[id]/languages.vue +537 -0
- package/pages/projects/[id]/review.vue +109 -0
- package/pages/projects/[id]/settings.vue +515 -0
- package/pages/projects/[id]/translations/[keyId].vue +642 -0
- package/pages/projects/[id]/translations/index.vue +250 -0
- package/pages/projects/[id]/users.vue +276 -0
- package/pages/projects/index.vue +334 -0
- package/pages/users/[id]/profile.vue +421 -0
- package/pages/users/index.vue +345 -0
- package/plugins/loading.client.ts +3 -0
- package/plugins/ui-i18n.ts +6 -0
- package/server/api/auth/login.post.ts +28 -0
- package/server/api/auth/logout.post.ts +7 -0
- package/server/api/auth/me.get.ts +11 -0
- package/server/api/auth/me.put.ts +31 -0
- package/server/api/auth/password.put.ts +27 -0
- package/server/api/auth/status.get.ts +16 -0
- package/server/api/config.get.ts +10 -0
- package/server/api/dashboard/layout.get.ts +18 -0
- package/server/api/dashboard/layout.post.ts +18 -0
- package/server/api/db-config.get.ts +44 -0
- package/server/api/db-config.post.ts +73 -0
- package/server/api/export.get.ts +64 -0
- package/server/api/formats/datetime/[id].delete.ts +8 -0
- package/server/api/formats/datetime/[id].put.ts +15 -0
- package/server/api/formats/datetime.get.ts +11 -0
- package/server/api/formats/datetime.post.ts +16 -0
- package/server/api/formats/modifiers/[id].delete.ts +8 -0
- package/server/api/formats/modifiers/[id].put.ts +10 -0
- package/server/api/formats/modifiers.get.ts +10 -0
- package/server/api/formats/modifiers.post.ts +14 -0
- package/server/api/formats/number/[id].delete.ts +8 -0
- package/server/api/formats/number/[id].put.ts +15 -0
- package/server/api/formats/number.get.ts +11 -0
- package/server/api/formats/number.post.ts +16 -0
- package/server/api/formats/snippet.get.ts +87 -0
- package/server/api/fs/browse.get.ts +50 -0
- package/server/api/history/[translationId].get.ts +13 -0
- package/server/api/keys/[id].delete.ts +14 -0
- package/server/api/keys/[id].get.ts +41 -0
- package/server/api/keys/[id].patch.ts +20 -0
- package/server/api/keys/index.get.ts +98 -0
- package/server/api/keys/index.post.ts +17 -0
- package/server/api/languages/[code].delete.ts +15 -0
- package/server/api/languages/[id].put.ts +24 -0
- package/server/api/languages/index.get.ts +13 -0
- package/server/api/languages/index.post.ts +42 -0
- package/server/api/onboarding.post.ts +56 -0
- package/server/api/profile.get.ts +81 -0
- package/server/api/project-snapshot.get.ts +73 -0
- package/server/api/project-snapshot.post.ts +160 -0
- package/server/api/projects/[id].delete.ts +13 -0
- package/server/api/projects/[id].put.ts +40 -0
- package/server/api/projects/index.get.ts +19 -0
- package/server/api/projects/index.post.ts +34 -0
- package/server/api/scan.post.ts +165 -0
- package/server/api/settings/index.get.ts +9 -0
- package/server/api/settings/index.post.ts +20 -0
- package/server/api/setup.post.ts +39 -0
- package/server/api/stats/global.get.ts +126 -0
- package/server/api/stats.get.ts +70 -0
- package/server/api/sync.post.ts +179 -0
- package/server/api/translate.post.ts +52 -0
- package/server/api/translations/batch-translate.post.ts +121 -0
- package/server/api/translations/bulk-status.post.ts +24 -0
- package/server/api/translations/index.post.ts +62 -0
- package/server/api/translations/job/[id].get.ts +23 -0
- package/server/api/translations/status.post.ts +30 -0
- package/server/api/translations/translate-all.post.ts +18 -0
- package/server/api/ui-locale.get.ts +39 -0
- package/server/api/users/[id]/profile.get.ts +107 -0
- package/server/api/users/[id]/roles.put.ts +67 -0
- package/server/api/users/[id].delete.ts +36 -0
- package/server/api/users/[id].put.ts +43 -0
- package/server/api/users/index.get.ts +49 -0
- package/server/api/users/index.post.ts +89 -0
- package/server/consts/auto-translate.const.ts +2 -0
- package/server/consts/commons.const.ts +10 -0
- package/server/consts/db.const.ts +3 -0
- package/server/consts/scanner.const.ts +4 -0
- package/server/consts/translation-job.const.ts +8 -0
- package/server/db/index.ts +672 -0
- package/server/enums/auth.enum.ts +5 -0
- package/server/enums/translation.enum.ts +6 -0
- package/server/interfaces/profile.interface.ts +48 -0
- package/server/interfaces/project-config.interface.ts +9 -0
- package/server/interfaces/scanner.interface.ts +18 -0
- package/server/interfaces/translation-job.interface.ts +13 -0
- package/server/middleware/auth.ts +32 -0
- package/server/plugins/db.ts +6 -0
- package/server/routes/locale/[lang].get.ts +179 -0
- package/server/types/auth.type.ts +3 -0
- package/server/utils/auth.util.ts +89 -0
- package/server/utils/auto-translate.util.ts +112 -0
- package/server/utils/lang-api.util.ts +24 -0
- package/server/utils/mailer.util.ts +80 -0
- package/server/utils/project-config.util.ts +37 -0
- package/server/utils/scanner.uti.ts +307 -0
- package/server/utils/translation-job.util.ts +142 -0
- package/services/auth.service.ts +31 -0
- package/services/base.service.ts +140 -0
- package/services/job.service.ts +10 -0
- package/services/key.service.ts +26 -0
- package/services/language.service.ts +26 -0
- package/services/profile.service.ts +14 -0
- package/services/project.service.ts +23 -0
- package/services/scan.service.ts +14 -0
- package/services/settings.service.ts +14 -0
- package/services/stats.service.ts +11 -0
- package/services/translation.service.ts +36 -0
- package/services/user.service.ts +28 -0
- package/tsconfig.json +3 -0
- package/types/commons.type.ts +3 -0
- package/types/dashboard.type.ts +26 -0
- 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>
|