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,537 @@
|
|
|
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('languages.title', 'Languages') }}</h1>
|
|
6
|
+
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">{{ t('languages.subtitle', 'Manage project languages') }}</p>
|
|
7
|
+
</div>
|
|
8
|
+
<UButton icon="i-heroicons-plus" @click="showAdd = true">{{ t('languages.add', 'Add a language') }}</UButton>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- Skeleton -->
|
|
12
|
+
<div v-if="pending" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
13
|
+
<UCard v-for="i in 3" :key="i">
|
|
14
|
+
<div class="flex items-center gap-3 mb-4">
|
|
15
|
+
<USkeleton class="w-10 h-10 rounded-full shrink-0" />
|
|
16
|
+
<div class="flex-1 space-y-1.5">
|
|
17
|
+
<USkeleton class="h-4 w-1/2" />
|
|
18
|
+
<USkeleton class="h-3 w-1/4" />
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<USkeleton class="h-2 w-full rounded-full mt-4" />
|
|
22
|
+
<USkeleton class="h-3 w-1/3 mt-2" />
|
|
23
|
+
</UCard>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Languages list -->
|
|
27
|
+
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
28
|
+
<UCard
|
|
29
|
+
v-for="lang in languages"
|
|
30
|
+
:key="lang.code"
|
|
31
|
+
class="relative"
|
|
32
|
+
>
|
|
33
|
+
<div class="flex items-start justify-between">
|
|
34
|
+
<div class="flex items-center gap-3">
|
|
35
|
+
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
|
36
|
+
<span class="text-xs font-bold text-primary-600 dark:text-primary-400 uppercase">{{ lang.code.split('-')[0] }}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div>
|
|
39
|
+
<p class="font-semibold text-gray-900 dark:text-white">{{ findLanguage(lang.code)?.nativeName || lang.name }}</p>
|
|
40
|
+
<p class="text-sm text-gray-400">{{ lang.code }}</p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="flex items-center gap-2">
|
|
45
|
+
<UBadge v-if="lang.is_default" color="primary" size="xs">{{ t('languages.default_badge', 'Default') }}</UBadge>
|
|
46
|
+
<UDropdownMenu :items="getLangActions(lang)">
|
|
47
|
+
<UButton color="neutral" icon="i-heroicons-ellipsis-vertical" size="xs" variant="ghost"/>
|
|
48
|
+
</UDropdownMenu>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
|
53
|
+
<div class="flex items-center justify-between text-sm">
|
|
54
|
+
<span class="text-gray-500">{{ t('languages.coverage', 'Coverage') }}</span>
|
|
55
|
+
<span class="font-medium text-gray-700 dark:text-gray-300">
|
|
56
|
+
{{ getCoverage(lang.code) }}%
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="mt-2 h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
|
60
|
+
<div
|
|
61
|
+
:class="getCoverage(lang.code) >= 80 ? 'bg-green-500' : getCoverage(lang.code) >= 50 ? 'bg-yellow-500' : 'bg-red-400'"
|
|
62
|
+
:style="{ width: `${getCoverage(lang.code)}%` }"
|
|
63
|
+
class="h-full rounded-full"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<p class="text-xs text-gray-400 mt-1">
|
|
67
|
+
{{ getTranslatedCount(lang.code) }} / {{ totalKeys }} {{ t('languages.keys_translated', 'keys translated') }}
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
<!-- Fallback indicator -->
|
|
71
|
+
<div class="mt-3 pt-2 border-t border-gray-100 dark:border-gray-800">
|
|
72
|
+
<button
|
|
73
|
+
class="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors w-full text-left"
|
|
74
|
+
@click="openFallbackModal(lang)"
|
|
75
|
+
>
|
|
76
|
+
<UIcon name="i-heroicons-arrow-uturn-left" class="text-xs shrink-0" />
|
|
77
|
+
<span v-if="lang.fallback_code">
|
|
78
|
+
{{ t('languages.fallback_label', 'Fallback') }}: <code class="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ lang.fallback_code }}</code>
|
|
79
|
+
</span>
|
|
80
|
+
<span v-else-if="getAutoBcp47Fallback(lang.code)">
|
|
81
|
+
{{ t('languages.fallback_auto', 'Auto fallback') }}: <code class="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ getAutoBcp47Fallback(lang.code) }}</code>
|
|
82
|
+
<UBadge size="xs" color="neutral" variant="soft" class="ml-1">BCP 47</UBadge>
|
|
83
|
+
</span>
|
|
84
|
+
<span v-else class="italic">{{ t('languages.no_fallback', 'No fallback') }}</span>
|
|
85
|
+
<UIcon name="i-heroicons-pencil-square" class="ml-auto text-xs opacity-40" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</UCard>
|
|
90
|
+
|
|
91
|
+
<!-- Empty state -->
|
|
92
|
+
<div v-if="!languages.length" class="col-span-full text-center py-16">
|
|
93
|
+
<UIcon class="text-5xl text-gray-300 dark:text-gray-600 mb-3" name="i-heroicons-flag"/>
|
|
94
|
+
<p class="text-gray-400 font-medium">{{ t('languages.none', 'No language configured') }}</p>
|
|
95
|
+
<p class="text-gray-400 text-sm mt-1">{{ t('languages.none_hint', 'Add languages to start translating.') }}</p>
|
|
96
|
+
<UButton class="mt-4" @click="showAdd = true">{{ t('languages.add', 'Add a language') }}</UButton>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<!-- Add Language modal -->
|
|
101
|
+
<UModal v-model:open="showAdd" :title="t('languages.add_modal_title', 'Add a language')">
|
|
102
|
+
<template #body>
|
|
103
|
+
<div class="space-y-4">
|
|
104
|
+
<UFormField :label="t('languages.language_label', 'Language')" required>
|
|
105
|
+
<UInput
|
|
106
|
+
v-model="langSearch"
|
|
107
|
+
:placeholder="t('onboarding.languages_search', 'Search for a language...')"
|
|
108
|
+
icon="i-heroicons-magnifying-glass"
|
|
109
|
+
class="w-full mb-2"
|
|
110
|
+
/>
|
|
111
|
+
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
112
|
+
<div class="max-h-52 overflow-y-auto">
|
|
113
|
+
<button
|
|
114
|
+
v-for="lang in filteredWorldLangs"
|
|
115
|
+
:key="lang.code"
|
|
116
|
+
class="w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
117
|
+
:class="selectedWorldLang?.code === lang.code ? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-gray-700 dark:text-gray-300'"
|
|
118
|
+
@click="selectWorldLang(lang)"
|
|
119
|
+
>
|
|
120
|
+
<span class="font-mono text-xs text-gray-400 w-14 shrink-0">{{ lang.code }}</span>
|
|
121
|
+
<span class="flex-1">{{ lang.nativeName }}</span>
|
|
122
|
+
<span class="text-xs text-gray-400 shrink-0">{{ lang.name }}</span>
|
|
123
|
+
<UIcon v-if="selectedWorldLang?.code === lang.code" name="i-heroicons-check" class="text-primary-500 shrink-0" />
|
|
124
|
+
</button>
|
|
125
|
+
<div v-if="!filteredWorldLangs.length" class="px-3 py-4 text-sm text-center text-gray-400">
|
|
126
|
+
{{ t('languages.none_found', 'No language found') }}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<!-- Custom BCP 47 entry when search looks like a code -->
|
|
131
|
+
<div
|
|
132
|
+
v-if="langSearch && isValidBcp47(langSearch) && !filteredWorldLangs.find(l => l.code.toLowerCase() === langSearch.toLowerCase())"
|
|
133
|
+
class="border-t border-gray-200 dark:border-gray-700"
|
|
134
|
+
>
|
|
135
|
+
<button
|
|
136
|
+
class="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
|
137
|
+
:class="selectedWorldLang?.code === langSearch ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300' : 'text-gray-500 dark:text-gray-400'"
|
|
138
|
+
@click="useCustomCode(langSearch)"
|
|
139
|
+
>
|
|
140
|
+
<UIcon name="i-heroicons-plus-circle" class="shrink-0 text-amber-500" />
|
|
141
|
+
<span class="flex-1">{{ t('languages.use_code', 'Use code') }} <code class="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ langSearch }}</code></span>
|
|
142
|
+
<UBadge size="xs" color="warning" variant="soft">BCP 47</UBadge>
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</UFormField>
|
|
147
|
+
|
|
148
|
+
<!-- Selected preview -->
|
|
149
|
+
<div v-if="selectedWorldLang" class="flex items-center gap-3 bg-primary-50 dark:bg-primary-900/20 rounded-lg px-3 py-2">
|
|
150
|
+
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/40 flex items-center justify-center shrink-0">
|
|
151
|
+
<span class="text-xs font-bold text-primary-600 dark:text-primary-400 uppercase">{{ selectedWorldLang.code.split('-')[0] }}</span>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="min-w-0 flex-1">
|
|
154
|
+
<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">{{ selectedWorldLang.nativeName }}</p>
|
|
155
|
+
<p class="text-xs font-mono text-gray-400">{{ selectedWorldLang.code }}</p>
|
|
156
|
+
</div>
|
|
157
|
+
<UBadge v-if="selectedWorldLang.code.includes('-')" size="xs" color="info" variant="soft">BCP 47</UBadge>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<UFormField label="">
|
|
161
|
+
<UCheckbox v-model="newLang.is_default" :label="t('languages.set_as_default', 'Set as default language')"/>
|
|
162
|
+
</UFormField>
|
|
163
|
+
</div>
|
|
164
|
+
</template>
|
|
165
|
+
<template #footer>
|
|
166
|
+
<div class="flex justify-end gap-3">
|
|
167
|
+
<UButton color="neutral" variant="ghost" @click="showAdd = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
168
|
+
<UButton :loading="adding" :disabled="!newLang.code" @click="addLanguage">{{ t('languages.add', 'Add') }}</UButton>
|
|
169
|
+
</div>
|
|
170
|
+
</template>
|
|
171
|
+
</UModal>
|
|
172
|
+
|
|
173
|
+
<!-- Fallback config modal -->
|
|
174
|
+
<UModal v-model:open="showFallbackModal" :title="t('languages.fallback_modal_title', 'Configure fallback')">
|
|
175
|
+
<template #body>
|
|
176
|
+
<div class="space-y-4">
|
|
177
|
+
<div v-if="fallbackTarget" class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
|
178
|
+
<span>{{ t('languages.language_label', 'Language') }}:</span>
|
|
179
|
+
<code class="font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded font-semibold text-gray-800 dark:text-gray-200">{{ fallbackTarget.code }}</code>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<!-- Info box -->
|
|
183
|
+
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2.5 text-xs text-blue-700 dark:text-blue-300 flex gap-2">
|
|
184
|
+
<UIcon name="i-heroicons-information-circle" class="shrink-0 mt-0.5" />
|
|
185
|
+
<div>
|
|
186
|
+
{{ t('languages.fallback_info', 'When a key is missing in') }} <strong>{{ fallbackTarget?.code }}</strong>, {{ t('languages.fallback_info2', 'the dashboard returns the value from the fallback language. Useful for') }} <code class="font-mono bg-blue-100 dark:bg-blue-900/40 px-1 rounded">fr-CA → fr</code> {{ t('languages.fallback_info3', 'or regional sub-variants.') }}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<UFormField :label="t('languages.fallback_language', 'Fallback language')">
|
|
191
|
+
<div class="space-y-1.5">
|
|
192
|
+
<!-- Auto BCP 47 option -->
|
|
193
|
+
<label
|
|
194
|
+
v-if="fallbackTarget && getAutoBcp47Fallback(fallbackTarget.code)"
|
|
195
|
+
class="flex items-center gap-3 px-3 py-2.5 rounded-lg border cursor-pointer transition-colors"
|
|
196
|
+
:class="fallbackChoice === '__auto__'
|
|
197
|
+
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700'
|
|
198
|
+
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
|
199
|
+
>
|
|
200
|
+
<input v-model="fallbackChoice" type="radio" value="__auto__" class="hidden" />
|
|
201
|
+
<div class="flex-1">
|
|
202
|
+
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
|
|
203
|
+
Auto BCP 47 —
|
|
204
|
+
<code class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ getAutoBcp47Fallback(fallbackTarget.code) }}</code>
|
|
205
|
+
</p>
|
|
206
|
+
<p class="text-xs text-gray-400">{{ t('languages.fallback_auto_detect', 'Automatic detection from language code') }}</p>
|
|
207
|
+
</div>
|
|
208
|
+
<UBadge size="xs" color="info" variant="soft">{{ t('languages.recommended', 'Recommended') }}</UBadge>
|
|
209
|
+
<div class="w-4 h-4 rounded-full border-2 shrink-0" :class="fallbackChoice === '__auto__' ? 'border-primary-500 bg-primary-500' : 'border-gray-300 dark:border-gray-600'" />
|
|
210
|
+
</label>
|
|
211
|
+
|
|
212
|
+
<!-- None option -->
|
|
213
|
+
<label
|
|
214
|
+
class="flex items-center gap-3 px-3 py-2.5 rounded-lg border cursor-pointer transition-colors"
|
|
215
|
+
:class="fallbackChoice === '__none__'
|
|
216
|
+
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700'
|
|
217
|
+
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
|
218
|
+
>
|
|
219
|
+
<input v-model="fallbackChoice" type="radio" value="__none__" class="hidden" />
|
|
220
|
+
<div class="flex-1">
|
|
221
|
+
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ t('languages.no_fallback', 'No fallback') }}</p>
|
|
222
|
+
<p class="text-xs text-gray-400">{{ t('languages.no_fallback_hint', 'Returns 404 if language is missing') }}</p>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="w-4 h-4 rounded-full border-2 shrink-0" :class="fallbackChoice === '__none__' ? 'border-primary-500 bg-primary-500' : 'border-gray-300 dark:border-gray-600'" />
|
|
225
|
+
</label>
|
|
226
|
+
|
|
227
|
+
<!-- Language list -->
|
|
228
|
+
<label
|
|
229
|
+
v-for="l in fallbackCandidates"
|
|
230
|
+
:key="l.code"
|
|
231
|
+
class="flex items-center gap-3 px-3 py-2.5 rounded-lg border cursor-pointer transition-colors"
|
|
232
|
+
:class="fallbackChoice === l.code
|
|
233
|
+
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700'
|
|
234
|
+
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
|
235
|
+
>
|
|
236
|
+
<input v-model="fallbackChoice" type="radio" :value="l.code" class="hidden" />
|
|
237
|
+
<div class="flex-1">
|
|
238
|
+
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
|
|
239
|
+
{{ findLanguage(l.code)?.nativeName || l.name }}
|
|
240
|
+
<code class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded ml-1">{{ l.code }}</code>
|
|
241
|
+
</p>
|
|
242
|
+
<p class="text-xs text-gray-400">{{ getCoverage(l.code) }}% {{ t('languages.translated', 'translated') }}</p>
|
|
243
|
+
</div>
|
|
244
|
+
<UBadge v-if="l.is_default" size="xs" color="primary" variant="soft">{{ t('languages.default_badge', 'Default') }}</UBadge>
|
|
245
|
+
<div class="w-4 h-4 rounded-full border-2 shrink-0" :class="fallbackChoice === l.code ? 'border-primary-500 bg-primary-500' : 'border-gray-300 dark:border-gray-600'" />
|
|
246
|
+
</label>
|
|
247
|
+
</div>
|
|
248
|
+
</UFormField>
|
|
249
|
+
|
|
250
|
+
<!-- Chain preview -->
|
|
251
|
+
<div v-if="fallbackChainPreview.length > 1" class="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2">
|
|
252
|
+
<p class="text-xs text-gray-400 mb-1.5">{{ t('languages.resolution_chain', 'Resolution chain:') }}</p>
|
|
253
|
+
<div class="flex items-center gap-1.5 flex-wrap">
|
|
254
|
+
<template v-for="(code, i) in fallbackChainPreview" :key="i">
|
|
255
|
+
<code class="text-xs font-mono bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 px-1.5 py-0.5 rounded text-gray-700 dark:text-gray-300">{{ code }}</code>
|
|
256
|
+
<span v-if="i < fallbackChainPreview.length - 1" class="text-gray-400 text-xs">→</span>
|
|
257
|
+
</template>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</template>
|
|
262
|
+
<template #footer>
|
|
263
|
+
<div class="flex justify-end gap-2">
|
|
264
|
+
<UButton color="neutral" variant="ghost" @click="showFallbackModal = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
265
|
+
<UButton :loading="savingFallback" @click="saveFallback">{{ t('common.save', 'Save') }}</UButton>
|
|
266
|
+
</div>
|
|
267
|
+
</template>
|
|
268
|
+
</UModal>
|
|
269
|
+
|
|
270
|
+
<!-- Translation progress modal -->
|
|
271
|
+
<UModal
|
|
272
|
+
v-model:open="showProgress"
|
|
273
|
+
:dismissible="false"
|
|
274
|
+
:title="`${t('languages.translating_to', 'Translating to')} ${progressLangName}`"
|
|
275
|
+
>
|
|
276
|
+
<template #body>
|
|
277
|
+
<div class="space-y-4 py-2">
|
|
278
|
+
<div v-if="progressTotal === 0" class="flex items-center gap-3 text-sm text-gray-500">
|
|
279
|
+
<UIcon name="i-heroicons-arrow-path" class="animate-spin text-primary-500 text-xl shrink-0" />
|
|
280
|
+
{{ t('languages.initializing', 'Initializing…') }}
|
|
281
|
+
</div>
|
|
282
|
+
<template v-else>
|
|
283
|
+
<div class="flex items-center justify-between text-sm">
|
|
284
|
+
<span class="text-gray-600 dark:text-gray-400">{{ progressDone }} / {{ progressTotal }} {{ t('languages.keys', 'keys') }}</span>
|
|
285
|
+
<span class="font-semibold text-gray-900 dark:text-white">{{ progressPercent }}%</span>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
|
288
|
+
<div
|
|
289
|
+
class="h-full bg-primary-500 rounded-full transition-all duration-500"
|
|
290
|
+
:style="{ width: `${progressPercent}%` }"
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
<p class="text-xs text-gray-400 text-center">
|
|
294
|
+
{{ t('languages.auto_translate_via', 'Auto-translation via Google Translate') }} — {{ progressTotal - progressDone }} {{ t('languages.remaining', 'remaining') }}
|
|
295
|
+
</p>
|
|
296
|
+
</template>
|
|
297
|
+
</div>
|
|
298
|
+
</template>
|
|
299
|
+
<template #footer>
|
|
300
|
+
<div class="flex justify-between items-center w-full">
|
|
301
|
+
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-down-tray" @click="sendToBackground">
|
|
302
|
+
{{ t('languages.send_to_background', 'Send to background') }}
|
|
303
|
+
</UButton>
|
|
304
|
+
<span v-if="progressStatus === 'done'" class="flex items-center gap-2 text-sm text-green-600 dark:text-green-400 font-medium">
|
|
305
|
+
<UIcon name="i-heroicons-check-circle" />
|
|
306
|
+
{{ t('languages.done', 'Done!') }}
|
|
307
|
+
</span>
|
|
308
|
+
<UButton v-if="progressStatus === 'done'" @click="closeProgress">
|
|
309
|
+
{{ t('common.close', 'Close') }}
|
|
310
|
+
</UButton>
|
|
311
|
+
</div>
|
|
312
|
+
</template>
|
|
313
|
+
</UModal>
|
|
314
|
+
|
|
315
|
+
<!-- Delete confirm modal -->
|
|
316
|
+
<UModal v-model:open="showDeleteConfirm" :title="t('languages.delete_modal_title', 'Delete language')">
|
|
317
|
+
<template #body>
|
|
318
|
+
<p class="text-gray-600 dark:text-gray-400">
|
|
319
|
+
{{ t('languages.delete_confirm', 'Delete') }} <strong>{{ deletingLang?.name }}</strong>?
|
|
320
|
+
{{ t('languages.delete_confirm2', 'This will also delete the') }} <strong>{{ getTranslatedCount(deletingLang?.code) }}</strong> {{ t('languages.delete_confirm3', 'associated translations.') }}
|
|
321
|
+
</p>
|
|
322
|
+
<p class="text-red-500 text-sm mt-2 font-medium">{{ t('common.irreversible', 'This action is irreversible.') }}</p>
|
|
323
|
+
</template>
|
|
324
|
+
<template #footer>
|
|
325
|
+
<div class="flex justify-end gap-3">
|
|
326
|
+
<UButton color="neutral" variant="ghost" @click="showDeleteConfirm = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
327
|
+
<UButton :loading="deleting" color="error" @click="deleteLanguage">{{ t('common.delete', 'Delete') }}</UButton>
|
|
328
|
+
</div>
|
|
329
|
+
</template>
|
|
330
|
+
</UModal>
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
333
|
+
|
|
334
|
+
<script lang="ts" setup>
|
|
335
|
+
const toast = useToast()
|
|
336
|
+
const { t } = useT()
|
|
337
|
+
const { currentProject } = useProject()
|
|
338
|
+
|
|
339
|
+
const showAdd = ref(false)
|
|
340
|
+
const showDeleteConfirm = ref(false)
|
|
341
|
+
const deletingLang = ref<any>(null)
|
|
342
|
+
|
|
343
|
+
const newLang = ref({ code: '', name: '', is_default: false })
|
|
344
|
+
|
|
345
|
+
const {
|
|
346
|
+
projectLanguages: languages,
|
|
347
|
+
pending,
|
|
348
|
+
adding,
|
|
349
|
+
addLanguage: doAddLanguage,
|
|
350
|
+
deleting,
|
|
351
|
+
deleteLanguage: doDeleteLanguage,
|
|
352
|
+
setDefault: doSetDefault,
|
|
353
|
+
setFallback: doSetFallback,
|
|
354
|
+
startTranslateAll,
|
|
355
|
+
refresh: refreshLanguages,
|
|
356
|
+
filteredLanguages,
|
|
357
|
+
searchQuery: langSearch,
|
|
358
|
+
findLanguage,
|
|
359
|
+
showProgress,
|
|
360
|
+
progressLangName,
|
|
361
|
+
progressTotal,
|
|
362
|
+
progressDone,
|
|
363
|
+
progressPercent,
|
|
364
|
+
progressStatus,
|
|
365
|
+
startPolling,
|
|
366
|
+
sendToBackground: doSendToBackground,
|
|
367
|
+
closeProgress,
|
|
368
|
+
} = useLanguages()
|
|
369
|
+
|
|
370
|
+
const { stats } = useStats()
|
|
371
|
+
|
|
372
|
+
const totalKeys = computed(() => (stats.value?.languages?.[0] as any)?.total || (stats.value as any)?.totalKeys || 0)
|
|
373
|
+
|
|
374
|
+
function getTranslatedCount(code: string): number {
|
|
375
|
+
return (stats.value?.languages as any[])?.find((l: any) => l.code === code)?.translated || 0
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getCoverage(code: string): number {
|
|
379
|
+
return (stats.value?.languages as any[])?.find((l: any) => l.code === code)?.coverage || 0
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// World language selector
|
|
383
|
+
const selectedWorldLang = ref<{ code: string; name: string; nativeName: string } | null>(null)
|
|
384
|
+
|
|
385
|
+
const existingCodes = computed(() => languages.value.map((l: any) => l.code))
|
|
386
|
+
const filteredWorldLangs = computed(() =>
|
|
387
|
+
filteredLanguages.value.filter((l) => !existingCodes.value.includes(l.code)),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
function selectWorldLang(lang: { code: string; name: string; nativeName: string }) {
|
|
391
|
+
selectedWorldLang.value = lang
|
|
392
|
+
newLang.value.code = lang.code
|
|
393
|
+
newLang.value.name = lang.name
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// BCP 47 custom code: language[-Script][-REGION][-variant]
|
|
397
|
+
function isValidBcp47(code: string): boolean {
|
|
398
|
+
return /^[a-z]{2,8}(-[A-Za-z0-9]{1,8})*$/i.test(code) && code.length >= 2
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function useCustomCode(code: string) {
|
|
402
|
+
const normalized = code.trim()
|
|
403
|
+
selectedWorldLang.value = { code: normalized, name: normalized, nativeName: normalized }
|
|
404
|
+
newLang.value.code = normalized
|
|
405
|
+
newLang.value.name = normalized
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Fallback configuration ────────────────────────────────────────────────
|
|
409
|
+
const showFallbackModal = ref(false)
|
|
410
|
+
const fallbackTarget = ref<any>(null)
|
|
411
|
+
const fallbackChoice = ref<string>('__auto__')
|
|
412
|
+
const savingFallback = ref(false)
|
|
413
|
+
|
|
414
|
+
function getAutoBcp47Fallback(code: string): string | null {
|
|
415
|
+
const parts = code.split('-')
|
|
416
|
+
if (parts.length <= 1) return null
|
|
417
|
+
parts.pop()
|
|
418
|
+
const base = parts.join('-')
|
|
419
|
+
// Only show auto if the base language actually exists in the project
|
|
420
|
+
return languages.value.find((l: any) => l.code === base) ? base : null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const fallbackCandidates = computed(() =>
|
|
424
|
+
(languages.value as any[]).filter((l) =>
|
|
425
|
+
fallbackTarget.value && l.code !== fallbackTarget.value.code,
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const fallbackChainPreview = computed(() => {
|
|
430
|
+
if (!fallbackTarget.value) return []
|
|
431
|
+
const chain: string[] = [fallbackTarget.value.code]
|
|
432
|
+
const choice = fallbackChoice.value
|
|
433
|
+
if (choice === '__none__') return chain
|
|
434
|
+
const next = choice === '__auto__'
|
|
435
|
+
? getAutoBcp47Fallback(fallbackTarget.value.code)
|
|
436
|
+
: choice
|
|
437
|
+
if (next) {
|
|
438
|
+
chain.push(next)
|
|
439
|
+
// Show one more level if that lang also has a fallback
|
|
440
|
+
const nextLang = (languages.value as any[]).find(l => l.code === next)
|
|
441
|
+
if (nextLang?.fallback_code) chain.push(nextLang.fallback_code)
|
|
442
|
+
else {
|
|
443
|
+
const autoNext = getAutoBcp47Fallback(next)
|
|
444
|
+
if (autoNext) chain.push(autoNext)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return chain
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
function openFallbackModal(lang: any) {
|
|
451
|
+
fallbackTarget.value = lang
|
|
452
|
+
if (lang.fallback_code) {
|
|
453
|
+
fallbackChoice.value = lang.fallback_code
|
|
454
|
+
} else if (getAutoBcp47Fallback(lang.code)) {
|
|
455
|
+
fallbackChoice.value = '__auto__'
|
|
456
|
+
} else {
|
|
457
|
+
fallbackChoice.value = '__none__'
|
|
458
|
+
}
|
|
459
|
+
showFallbackModal.value = true
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function saveFallback() {
|
|
463
|
+
if (!fallbackTarget.value) return
|
|
464
|
+
savingFallback.value = true
|
|
465
|
+
try {
|
|
466
|
+
const explicit = fallbackChoice.value === '__auto__' || fallbackChoice.value === '__none__'
|
|
467
|
+
? null
|
|
468
|
+
: fallbackChoice.value
|
|
469
|
+
await doSetFallback(fallbackTarget.value, explicit)
|
|
470
|
+
toast.add({ title: t('languages.fallback_saved', 'Fallback configured'), color: 'success' })
|
|
471
|
+
showFallbackModal.value = false
|
|
472
|
+
} catch (e: any) {
|
|
473
|
+
toast.add({ title: t('common.error', 'Error'), description: e.message, color: 'error' })
|
|
474
|
+
} finally {
|
|
475
|
+
savingFallback.value = false
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function getLangActions(lang: any) {
|
|
480
|
+
return [
|
|
481
|
+
[
|
|
482
|
+
...(lang.is_default ? [] : [{
|
|
483
|
+
label: t('languages.set_default', 'Set as default'),
|
|
484
|
+
icon: 'i-heroicons-star',
|
|
485
|
+
onSelect: () => doSetDefault(lang),
|
|
486
|
+
}]),
|
|
487
|
+
{
|
|
488
|
+
label: t('common.delete', 'Delete'),
|
|
489
|
+
icon: 'i-heroicons-trash',
|
|
490
|
+
color: 'error' as const,
|
|
491
|
+
onSelect: () => confirmDelete(lang),
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function sendToBackground() {
|
|
498
|
+
doSendToBackground(() => {
|
|
499
|
+
refreshLanguages()
|
|
500
|
+
refreshNuxtData('project-stats')
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Add language ─────────────────────────────────────────────────────────
|
|
505
|
+
async function addLanguage() {
|
|
506
|
+
if (!newLang.value.code || !newLang.value.name || !currentProject.value) return
|
|
507
|
+
try {
|
|
508
|
+
await doAddLanguage({ code: newLang.value.code, name: newLang.value.name, is_default: newLang.value.is_default })
|
|
509
|
+
showAdd.value = false
|
|
510
|
+
|
|
511
|
+
if (totalKeys.value > 0 && !currentProject.value.is_system) {
|
|
512
|
+
const langName = selectedWorldLang.value?.nativeName || newLang.value.name
|
|
513
|
+
const jobId = await startTranslateAll(newLang.value.code, newLang.value.name)
|
|
514
|
+
if (jobId) startPolling(jobId, langName)
|
|
515
|
+
} else {
|
|
516
|
+
toast.add({ title: t('languages.added', 'Language added'), color: 'success' })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
newLang.value = { code: '', name: '', is_default: false }
|
|
520
|
+
selectedWorldLang.value = null
|
|
521
|
+
langSearch.value = ''
|
|
522
|
+
} catch (e: any) {
|
|
523
|
+
toast.add({ title: t('common.error', 'Error'), description: e.message, color: 'error' })
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function confirmDelete(lang: any) {
|
|
528
|
+
deletingLang.value = lang
|
|
529
|
+
showDeleteConfirm.value = true
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function deleteLanguage() {
|
|
533
|
+
if (!deletingLang.value) return
|
|
534
|
+
await doDeleteLanguage(deletingLang.value.code)
|
|
535
|
+
showDeleteConfirm.value = false
|
|
536
|
+
}
|
|
537
|
+
</script>
|
|
@@ -0,0 +1,109 @@
|
|
|
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('review.title', 'Review queue') }}</h1>
|
|
6
|
+
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
|
|
7
|
+
{{ reviewItems.length }} {{ t('review.pending_count', 'translation') }}{{ reviewItems.length > 1 ? t('review.pending_count_plural', 's') : '' }} {{ t('review.pending_label', 'pending review') }}
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
<UButton
|
|
11
|
+
v-if="reviewItems.length > 0"
|
|
12
|
+
icon="i-heroicons-check-circle"
|
|
13
|
+
:loading="approvingAll"
|
|
14
|
+
@click="approveAll"
|
|
15
|
+
>
|
|
16
|
+
{{ t('review.mark_all_reviewed', 'Mark all as reviewed') }} ({{ reviewItems.length }})
|
|
17
|
+
</UButton>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Loading -->
|
|
21
|
+
<div v-if="pending" class="space-y-3">
|
|
22
|
+
<USkeleton v-for="i in 5" :key="i" class="h-20" />
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Empty -->
|
|
26
|
+
<div v-else-if="!reviewItems.length" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 py-16 text-center">
|
|
27
|
+
<UIcon name="i-heroicons-check-badge" class="text-5xl text-green-400 mb-3" />
|
|
28
|
+
<p class="text-gray-600 dark:text-gray-400 font-medium">{{ t('review.empty_title', 'No translations pending') }}</p>
|
|
29
|
+
<p class="text-gray-400 text-sm mt-1">{{ t('review.empty_hint', 'All reviewed translations have already been approved.') }}</p>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<!-- Review list -->
|
|
33
|
+
<div v-else class="space-y-3">
|
|
34
|
+
<div
|
|
35
|
+
v-for="item in reviewItems"
|
|
36
|
+
:key="item.id"
|
|
37
|
+
class="bg-white dark:bg-gray-900 rounded-xl border border-blue-200 dark:border-blue-800/60 overflow-hidden"
|
|
38
|
+
>
|
|
39
|
+
<div class="flex items-start gap-4 p-4">
|
|
40
|
+
<!-- Status badge -->
|
|
41
|
+
<div class="shrink-0 mt-0.5">
|
|
42
|
+
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-semibold bg-yellow-50 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-700">
|
|
43
|
+
<span class="w-1.5 h-1.5 rounded-full bg-yellow-400 shrink-0" />
|
|
44
|
+
{{ t('status.draft', 'Draft') }}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Key info -->
|
|
49
|
+
<div class="min-w-0 flex-1">
|
|
50
|
+
<div class="flex items-center gap-2 mb-1">
|
|
51
|
+
<span class="text-xs font-mono font-semibold text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30 px-2 py-0.5 rounded">
|
|
52
|
+
{{ item.key }}
|
|
53
|
+
</span>
|
|
54
|
+
<UBadge size="xs" variant="outline" color="neutral">{{ item.language_code.toUpperCase() }}</UBadge>
|
|
55
|
+
</div>
|
|
56
|
+
<p v-if="item.key_description" class="text-xs text-gray-400 mb-2">{{ item.key_description }}</p>
|
|
57
|
+
<p class="text-sm text-gray-700 dark:text-gray-300 leading-snug">{{ item.value }}</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Actions -->
|
|
61
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
62
|
+
<UTooltip :text="t('review.reject', 'Reject')">
|
|
63
|
+
<UButton
|
|
64
|
+
icon="i-heroicons-x-mark"
|
|
65
|
+
color="error"
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="sm"
|
|
68
|
+
:loading="processingId === item.id && processingAction === TRANSLATION_STATUS.REJECTED"
|
|
69
|
+
@click="setStatus(item, TRANSLATION_STATUS.REJECTED)"
|
|
70
|
+
/>
|
|
71
|
+
</UTooltip>
|
|
72
|
+
<UButton
|
|
73
|
+
icon="i-heroicons-check"
|
|
74
|
+
color="success"
|
|
75
|
+
size="sm"
|
|
76
|
+
:loading="processingId === item.id && processingAction === TRANSLATION_STATUS.REVIEWED"
|
|
77
|
+
@click="setStatus(item, TRANSLATION_STATUS.REVIEWED)"
|
|
78
|
+
>
|
|
79
|
+
{{ t('review.mark_reviewed', 'Mark as reviewed') }}
|
|
80
|
+
</UButton>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<script setup lang="ts">
|
|
89
|
+
import { TRANSLATION_STATUS } from '~/server/enums/translation.enum'
|
|
90
|
+
|
|
91
|
+
const { currentProject } = useProject()
|
|
92
|
+
const { canApprove } = useAuth()
|
|
93
|
+
const { t } = useT()
|
|
94
|
+
|
|
95
|
+
const hasAccess = computed(() =>
|
|
96
|
+
currentProject.value ? canApprove(currentProject.value.id) : false,
|
|
97
|
+
)
|
|
98
|
+
watch(hasAccess, (ok) => { if (!ok) navigateTo('/') }, { immediate: true })
|
|
99
|
+
|
|
100
|
+
const {
|
|
101
|
+
reviewItems,
|
|
102
|
+
pending,
|
|
103
|
+
processingId,
|
|
104
|
+
processingAction,
|
|
105
|
+
setStatus,
|
|
106
|
+
approvingAll,
|
|
107
|
+
markAllReviewed: approveAll,
|
|
108
|
+
} = useReview()
|
|
109
|
+
</script>
|