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,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>
|