i18n-dashboard 0.7.0 → 0.11.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/README.md +6 -2
- package/composables/useKeys.ts +20 -5
- package/composables/useLanguages.ts +18 -5
- package/composables/useProfile.ts +20 -7
- package/composables/useSettings.ts +17 -5
- package/composables/useUsers.ts +20 -5
- package/layouts/default.vue +3 -1
- package/package.json +15 -3
- package/pages/index.vue +1 -1
- package/pages/login.vue +5 -3
- package/pages/projects/[id]/languages.vue +8 -6
- package/pages/projects/[id]/review.vue +5 -3
- package/pages/projects/[id]/settings.vue +7 -3
- package/pages/projects/[id]/translations/[keyId].vue +12 -8
- package/pages/projects/[id]/translations/index.vue +7 -5
- package/pages/projects/[id]/users.vue +12 -10
- package/pages/projects/index.vue +15 -12
- package/pages/users/[id]/profile.vue +9 -7
- package/pages/users/index.vue +9 -8
- package/server/db/index.ts +331 -0
package/README.md
CHANGED
|
@@ -66,7 +66,10 @@
|
|
|
66
66
|
- **CORS auto-detection** — multiple app URLs per project; all are checked for CORS on `/locale/[lang].json`
|
|
67
67
|
- **Global loading overlay** — a full-page loading screen prevents interaction before data is ready, including on direct page load (F5) for any route
|
|
68
68
|
- **Dark mode** — system preference + manual toggle
|
|
69
|
-
|
|
69
|
+
- **Cypress E2E test suite** — full test coverage across all pages (auth, dashboard, projects, translations, languages, users, review, settings); CI-ready with GitHub Actions
|
|
70
|
+
- **GitHub Actions CI** — E2E tests run automatically on every push to `develop` and `main`; Cypress screenshots uploaded as artifacts on failure
|
|
71
|
+
- **Vitest unit test suite** — 344 unit tests covering all composables, services, and server utilities; runs in under 2 minutes with zero infrastructure required
|
|
72
|
+
- **Dual CI pipelines** — unit tests (`unit.yml`) and E2E tests (`e2e.yml`) run in parallel on every push; any regression blocks the pipeline
|
|
70
73
|
---
|
|
71
74
|
|
|
72
75
|
## Requirements
|
|
@@ -723,7 +726,8 @@ npx i18n-dashboard sync
|
|
|
723
726
|
| [@vitalets/google-translate-api](https://github.com/vitalets/google-translate-api) | 9.x | Google Translate (free tier) |
|
|
724
727
|
| [Commander.js](https://github.com/tj/commander.js) | 13.x | CLI |
|
|
725
728
|
| [bcryptjs](https://github.com/dcodeIO/bcrypt.js) | 2.x | Password hashing |
|
|
726
|
-
|
|
729
|
+
| [Cypress](https://www.cypress.io/) | 13.x | E2E test suite |
|
|
730
|
+
| [Vitest](https://vitest.dev/) | 4.x | Unit test suite (composables, services, server utils) |
|
|
727
731
|
---
|
|
728
732
|
|
|
729
733
|
## Contributing
|
package/composables/useKeys.ts
CHANGED
|
@@ -114,11 +114,26 @@ export function useKeys(options: {
|
|
|
114
114
|
return Array.isArray(v) ? v[0] : v
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
const keyData = ref<Awaited<ReturnType<typeof keyService.getKey>> | null>(null)
|
|
118
|
+
const detailPending = ref(false)
|
|
119
|
+
|
|
120
|
+
async function detailRefresh() {
|
|
121
|
+
const id = keyId.value
|
|
122
|
+
if (!id) { keyData.value = null; return }
|
|
123
|
+
detailPending.value = true
|
|
124
|
+
try {
|
|
125
|
+
keyData.value = await keyService.getKey(id)
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
keyData.value = null
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
detailPending.value = false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onMounted(detailRefresh)
|
|
136
|
+
watch(keyId, (id) => { if (id) detailRefresh() })
|
|
122
137
|
|
|
123
138
|
const savingLang = ref<string | null>(null)
|
|
124
139
|
async function saveTranslation(langCode: string, value: string): Promise<void> {
|
|
@@ -38,11 +38,24 @@ export function useLanguages() {
|
|
|
38
38
|
|
|
39
39
|
// ── Project languages (API) ──────────────────────────────────────────────
|
|
40
40
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
const data = ref<LanguageItem[]>([])
|
|
42
|
+
const pending = ref(false)
|
|
43
|
+
|
|
44
|
+
async function refresh() {
|
|
45
|
+
pending.value = true
|
|
46
|
+
try {
|
|
47
|
+
data.value = await languageService.getLanguages(currentProject.value?.id)
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
data.value = []
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
pending.value = false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onMounted(refresh)
|
|
58
|
+
watch(() => currentProject.value?.id, (id) => { if (id) refresh() })
|
|
46
59
|
|
|
47
60
|
const projectLanguages = computed(() => data.value ?? [])
|
|
48
61
|
|
|
@@ -12,13 +12,26 @@ export function useProfile(userId?: MaybeRefOrGetter<number | string>) {
|
|
|
12
12
|
const targetId = computed(() => userId ? toValue(userId) : null)
|
|
13
13
|
const period = ref<ProfilePeriod>('all')
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{
|
|
21
|
-
|
|
15
|
+
const profile = ref<any>(null)
|
|
16
|
+
const pending = ref(false)
|
|
17
|
+
|
|
18
|
+
async function refresh() {
|
|
19
|
+
pending.value = true
|
|
20
|
+
try {
|
|
21
|
+
profile.value = targetId.value
|
|
22
|
+
? await profileService.getUserProfile(targetId.value, period.value)
|
|
23
|
+
: await profileService.getProfile(period.value)
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
profile.value = null
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
pending.value = false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onMounted(refresh)
|
|
34
|
+
watch([targetId, period], refresh)
|
|
22
35
|
|
|
23
36
|
// ── Own account editing (current logged-in user) ─────────────────────────
|
|
24
37
|
|
|
@@ -5,11 +5,23 @@ export function useSettings() {
|
|
|
5
5
|
const toast = useToast()
|
|
6
6
|
const { t } = useT()
|
|
7
7
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
const data = ref<Record<string, string>>({})
|
|
9
|
+
const pending = ref(false)
|
|
10
|
+
|
|
11
|
+
async function refresh() {
|
|
12
|
+
pending.value = true
|
|
13
|
+
try {
|
|
14
|
+
data.value = await settingsService.getSettings()
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
data.value = {}
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
pending.value = false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onMounted(refresh)
|
|
13
25
|
|
|
14
26
|
const settings = computed(() => data.value ?? {})
|
|
15
27
|
|
package/composables/useUsers.ts
CHANGED
|
@@ -11,11 +11,26 @@ export function useUsers(scope: 'project' | 'global' = 'project') {
|
|
|
11
11
|
scope === 'global' ? {} : { project_id: currentProject.value?.id },
|
|
12
12
|
)
|
|
13
13
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
const data = ref<any[]>([])
|
|
15
|
+
const pending = ref(false)
|
|
16
|
+
|
|
17
|
+
async function refresh() {
|
|
18
|
+
pending.value = true
|
|
19
|
+
try {
|
|
20
|
+
data.value = await userService.getUsers(usersQuery.value)
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
data.value = []
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
pending.value = false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onMounted(refresh)
|
|
31
|
+
watch(usersQuery, (q) => {
|
|
32
|
+
if (scope === 'global' || q.project_id) refresh()
|
|
33
|
+
})
|
|
19
34
|
|
|
20
35
|
const users = computed(() => data.value ?? [])
|
|
21
36
|
|
package/layouts/default.vue
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex">
|
|
10
10
|
|
|
11
11
|
<!-- ── Sidebar ─────────────────────────────────────────────────────────── -->
|
|
12
|
-
<aside class="w-56 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
|
|
12
|
+
<aside data-cy="main-sidebar" class="w-56 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
|
|
13
13
|
|
|
14
14
|
<!-- Logo -->
|
|
15
15
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
<NuxtLink
|
|
90
90
|
v-for="project in userProjects"
|
|
91
91
|
:key="project.id"
|
|
92
|
+
:data-cy="'sidebar-project-' + project.id"
|
|
92
93
|
:to="`/projects/${project.id}`"
|
|
93
94
|
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors text-left"
|
|
94
95
|
:class="'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'"
|
|
@@ -115,6 +116,7 @@
|
|
|
115
116
|
<div v-if="isSuperAdmin" class="p-2 border-t border-gray-200 dark:border-gray-800">
|
|
116
117
|
<p class="text-xs text-gray-400 font-medium px-2 mb-1.5 uppercase tracking-wide">{{ t('nav.administration', 'Administration') }}</p>
|
|
117
118
|
<NuxtLink
|
|
119
|
+
data-cy="sidebar-all-users-link"
|
|
118
120
|
to="/users"
|
|
119
121
|
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
|
|
120
122
|
:class="isActive('/users')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A web dashboard to manage vue-i18n translation keys with database persistence",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
"start:detach": "node bin/cli.mjs start --detach",
|
|
16
16
|
"stop": "node bin/cli.mjs stop",
|
|
17
17
|
"init": "node bin/cli.mjs init",
|
|
18
|
-
"sync": "node bin/cli.mjs sync"
|
|
18
|
+
"sync": "node bin/cli.mjs sync",
|
|
19
|
+
"cy:open": "cypress open",
|
|
20
|
+
"cy:run": "cypress run",
|
|
21
|
+
"test": "vitest",
|
|
22
|
+
"test:ui": "vitest --ui",
|
|
23
|
+
"test:run": "vitest run",
|
|
24
|
+
"test:coverage": "vitest run --coverage"
|
|
19
25
|
},
|
|
20
26
|
"dependencies": {
|
|
21
27
|
"@nuxt/ui": "^3.3.7",
|
|
@@ -84,6 +90,12 @@
|
|
|
84
90
|
"license": "MIT",
|
|
85
91
|
"devDependencies": {
|
|
86
92
|
"@iconify-json/heroicons": "^1.2.3",
|
|
87
|
-
"@types/node": "^25.3.5"
|
|
93
|
+
"@types/node": "^25.3.5",
|
|
94
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
95
|
+
"@vitest/ui": "^4.1.0",
|
|
96
|
+
"@vue/test-utils": "^2.4.6",
|
|
97
|
+
"cypress": "^15.12.0",
|
|
98
|
+
"happy-dom": "^20.8.4",
|
|
99
|
+
"vitest": "^4.1.0"
|
|
88
100
|
}
|
|
89
101
|
}
|
package/pages/index.vue
CHANGED
package/pages/login.vue
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<UIcon name="i-heroicons-language" class="text-white text-lg" />
|
|
7
7
|
</div>
|
|
8
8
|
<div>
|
|
9
|
-
<h1 class="text-lg font-bold text-gray-900 dark:text-white">i18n Dashboard</h1>
|
|
9
|
+
<h1 data-cy="login-title" class="text-lg font-bold text-gray-900 dark:text-white">i18n Dashboard</h1>
|
|
10
10
|
<p class="text-xs text-gray-400">{{ t('login.title', 'Log in') }}</p>
|
|
11
11
|
</div>
|
|
12
12
|
</div>
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
class="w-full"
|
|
22
22
|
autocomplete="email"
|
|
23
23
|
autofocus
|
|
24
|
+
data-cy="login-email"
|
|
24
25
|
/>
|
|
25
26
|
</UFormField>
|
|
26
27
|
|
|
@@ -31,15 +32,16 @@
|
|
|
31
32
|
placeholder="••••••••"
|
|
32
33
|
class="w-full"
|
|
33
34
|
autocomplete="current-password"
|
|
35
|
+
data-cy="login-password"
|
|
34
36
|
/>
|
|
35
37
|
</UFormField>
|
|
36
38
|
|
|
37
|
-
<p v-if="error" class="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2">
|
|
39
|
+
<p v-if="error" data-cy="login-error" class="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2">
|
|
38
40
|
<UIcon name="i-heroicons-exclamation-circle" class="inline mr-1" />
|
|
39
41
|
{{ error }}
|
|
40
42
|
</p>
|
|
41
43
|
|
|
42
|
-
<UButton type="submit" block :loading="loading" class="mt-2">
|
|
44
|
+
<UButton type="submit" block :loading="loading" class="mt-2" data-cy="login-submit">
|
|
43
45
|
{{ t('login.submit', 'Sign in') }}
|
|
44
46
|
</UButton>
|
|
45
47
|
</form>
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
<div class="p-6">
|
|
3
3
|
<div class="flex items-center justify-between mb-6">
|
|
4
4
|
<div>
|
|
5
|
-
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('languages.title', 'Languages') }}</h1>
|
|
5
|
+
<h1 data-cy="languages-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('languages.title', 'Languages') }}</h1>
|
|
6
6
|
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">{{ t('languages.subtitle', 'Manage project languages') }}</p>
|
|
7
7
|
</div>
|
|
8
|
-
<UButton icon="i-heroicons-plus" @click="showAdd = true">{{ t('languages.add', 'Add a language') }}</UButton>
|
|
8
|
+
<UButton data-cy="languages-add-btn" icon="i-heroicons-plus" @click="showAdd = true">{{ t('languages.add', 'Add a language') }}</UButton>
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
11
|
<!-- Skeleton -->
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
<UCard
|
|
29
29
|
v-for="lang in languages"
|
|
30
30
|
:key="lang.code"
|
|
31
|
+
:data-cy="'language-card-' + lang.code"
|
|
31
32
|
class="relative"
|
|
32
33
|
>
|
|
33
34
|
<div class="flex items-start justify-between">
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
</div>
|
|
43
44
|
|
|
44
45
|
<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
|
+
<UBadge v-if="lang.is_default" :data-cy="'lang-default-badge-' + lang.code" color="primary" size="xs">{{ t('languages.default_badge', 'Default') }}</UBadge>
|
|
46
47
|
<UDropdownMenu :items="getLangActions(lang)">
|
|
47
48
|
<UButton color="neutral" icon="i-heroicons-ellipsis-vertical" size="xs" variant="ghost"/>
|
|
48
49
|
</UDropdownMenu>
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
|
53
54
|
<div class="flex items-center justify-between text-sm">
|
|
54
55
|
<span class="text-gray-500">{{ t('languages.coverage', 'Coverage') }}</span>
|
|
55
|
-
<span class="font-medium text-gray-700 dark:text-gray-300">
|
|
56
|
+
<span :data-cy="'lang-coverage-' + lang.code" class="font-medium text-gray-700 dark:text-gray-300">
|
|
56
57
|
{{ getCoverage(lang.code) }}%
|
|
57
58
|
</span>
|
|
58
59
|
</div>
|
|
@@ -98,11 +99,12 @@
|
|
|
98
99
|
</div>
|
|
99
100
|
|
|
100
101
|
<!-- Add Language modal -->
|
|
101
|
-
<UModal v-model:open="showAdd" :title="t('languages.add_modal_title', 'Add a language')">
|
|
102
|
+
<UModal data-cy="add-language-modal" v-model:open="showAdd" :title="t('languages.add_modal_title', 'Add a language')">
|
|
102
103
|
<template #body>
|
|
103
104
|
<div class="space-y-4">
|
|
104
105
|
<UFormField :label="t('languages.language_label', 'Language')" required>
|
|
105
106
|
<UInput
|
|
107
|
+
data-cy="lang-search-input"
|
|
106
108
|
v-model="langSearch"
|
|
107
109
|
:placeholder="t('onboarding.languages_search', 'Search for a language...')"
|
|
108
110
|
icon="i-heroicons-magnifying-glass"
|
|
@@ -164,7 +166,7 @@
|
|
|
164
166
|
</template>
|
|
165
167
|
<template #footer>
|
|
166
168
|
<div class="flex justify-end gap-3">
|
|
167
|
-
<UButton color="neutral" variant="ghost" @click="showAdd = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
169
|
+
<UButton data-cy="add-language-cancel-btn" color="neutral" variant="ghost" @click="showAdd = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
168
170
|
<UButton :loading="adding" :disabled="!newLang.code" @click="addLanguage">{{ t('languages.add', 'Add') }}</UButton>
|
|
169
171
|
</div>
|
|
170
172
|
</template>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<div class="p-6">
|
|
3
3
|
<div class="flex items-center justify-between mb-6">
|
|
4
4
|
<div>
|
|
5
|
-
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('review.title', 'Review queue') }}</h1>
|
|
5
|
+
<h1 data-cy="review-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('review.title', 'Review queue') }}</h1>
|
|
6
6
|
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
|
|
7
7
|
{{ reviewItems.length }} {{ t('review.pending_count', 'translation') }}{{ reviewItems.length > 1 ? t('review.pending_count_plural', 's') : '' }} {{ t('review.pending_label', 'pending review') }}
|
|
8
8
|
</p>
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
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">
|
|
26
|
+
<div v-else-if="!reviewItems.length" data-cy="review-empty-state" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 py-16 text-center">
|
|
27
27
|
<UIcon name="i-heroicons-check-badge" class="text-5xl text-green-400 mb-3" />
|
|
28
28
|
<p class="text-gray-600 dark:text-gray-400 font-medium">{{ t('review.empty_title', 'No translations pending') }}</p>
|
|
29
29
|
<p class="text-gray-400 text-sm mt-1">{{ t('review.empty_hint', 'All reviewed translations have already been approved.') }}</p>
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
<div
|
|
35
35
|
v-for="item in reviewItems"
|
|
36
36
|
:key="item.id"
|
|
37
|
+
:data-cy="'review-item-' + item.id"
|
|
37
38
|
class="bg-white dark:bg-gray-900 rounded-xl border border-blue-200 dark:border-blue-800/60 overflow-hidden"
|
|
38
39
|
>
|
|
39
40
|
<div class="flex items-start gap-4 p-4">
|
|
@@ -70,6 +71,7 @@
|
|
|
70
71
|
/>
|
|
71
72
|
</UTooltip>
|
|
72
73
|
<UButton
|
|
74
|
+
data-cy="mark-reviewed-btn"
|
|
73
75
|
icon="i-heroicons-check"
|
|
74
76
|
color="success"
|
|
75
77
|
size="sm"
|
|
@@ -95,7 +97,7 @@ const { t } = useT()
|
|
|
95
97
|
const hasAccess = computed(() =>
|
|
96
98
|
currentProject.value ? canApprove(currentProject.value.id) : false,
|
|
97
99
|
)
|
|
98
|
-
watch(hasAccess, (ok) => { if (!ok) navigateTo('/') }, { immediate: true })
|
|
100
|
+
watch(hasAccess, (ok) => { if (import.meta.client && !ok) navigateTo('/') }, { immediate: true })
|
|
99
101
|
|
|
100
102
|
const {
|
|
101
103
|
reviewItems,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="p-6">
|
|
3
3
|
<div class="mb-6">
|
|
4
|
-
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.title', 'Settings') }}</h1>
|
|
4
|
+
<h1 data-cy="settings-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('settings.title', 'Settings') }}</h1>
|
|
5
5
|
<p class="text-gray-500 dark:text-gray-400 mt-1">{{ t('settings.subtitle', 'Global dashboard configuration') }}</p>
|
|
6
6
|
</div>
|
|
7
7
|
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
:label="t('settings.scan_exclude', 'Folders excluded from scan')"
|
|
88
88
|
>
|
|
89
89
|
<UInput
|
|
90
|
+
data-cy="settings-scan-exclude"
|
|
90
91
|
v-model="form.scan_exclude"
|
|
91
92
|
class="w-full"
|
|
92
93
|
placeholder="node_modules,dist,.nuxt,.output"
|
|
@@ -227,6 +228,7 @@
|
|
|
227
228
|
<p class="text-xs text-gray-400">{{ t('settings.export_all_languages_hint', 'A single JSON file with all languages') }}</p>
|
|
228
229
|
</div>
|
|
229
230
|
<UButton
|
|
231
|
+
data-cy="settings-export-all-btn"
|
|
230
232
|
color="neutral"
|
|
231
233
|
icon="i-heroicons-arrow-down-tray"
|
|
232
234
|
size="sm"
|
|
@@ -251,6 +253,7 @@
|
|
|
251
253
|
<UBadge v-if="lang.is_default" color="primary" size="xs" variant="soft">{{ t('languages.default_badge', 'Default') }}</UBadge>
|
|
252
254
|
</div>
|
|
253
255
|
<UButton
|
|
256
|
+
:data-cy="'export-lang-btn-' + lang.code"
|
|
254
257
|
color="neutral"
|
|
255
258
|
icon="i-heroicons-arrow-down-tray"
|
|
256
259
|
size="xs"
|
|
@@ -339,7 +342,7 @@
|
|
|
339
342
|
|
|
340
343
|
<!-- Save button -->
|
|
341
344
|
<div class="flex justify-end">
|
|
342
|
-
<UButton :loading="saving || savingProject" icon="i-heroicons-check" @click="onSave">
|
|
345
|
+
<UButton data-cy="settings-save-btn" :loading="saving || savingProject" icon="i-heroicons-check" @click="onSave">
|
|
343
346
|
{{ t('settings.save', 'Save') }}
|
|
344
347
|
</UButton>
|
|
345
348
|
</div>
|
|
@@ -417,7 +420,8 @@
|
|
|
417
420
|
}
|
|
418
421
|
}, { immediate: true })
|
|
419
422
|
|
|
420
|
-
const
|
|
423
|
+
const reqUrl = useRequestURL()
|
|
424
|
+
const apiAddress = reqUrl.origin
|
|
421
425
|
|
|
422
426
|
const apiExamples = computed(() => [
|
|
423
427
|
{ label: t('settings.api_example_english', 'English translations'), url: `${apiAddress}/locale/en.json` },
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
|
|
45
45
|
<!-- Header -->
|
|
46
46
|
<div class="flex items-start gap-3">
|
|
47
|
-
<UButton icon="i-heroicons-arrow-left" color="neutral" variant="ghost" size="xs" :to="`/projects/${projectId}/translations`" class="mt-0.5 shrink-0" />
|
|
47
|
+
<UButton data-cy="key-back-link" icon="i-heroicons-arrow-left" color="neutral" variant="ghost" size="xs" :to="`/projects/${projectId}/translations`" class="mt-0.5 shrink-0" />
|
|
48
48
|
<div class="flex-1 min-w-0">
|
|
49
49
|
<div class="flex items-center gap-2 flex-wrap">
|
|
50
|
-
<h1 class="text-lg font-mono font-bold text-gray-900 dark:text-white break-all">{{ keyData.key }}</h1>
|
|
50
|
+
<h1 data-cy="key-title" class="text-lg font-mono font-bold text-gray-900 dark:text-white break-all">{{ keyData.key }}</h1>
|
|
51
51
|
<UBadge v-if="keyData.is_unused" color="warning" variant="subtle" size="xs">
|
|
52
52
|
<UIcon name="i-heroicons-exclamation-triangle" class="mr-1" />
|
|
53
53
|
{{ t('status.unused', 'Unused') }}
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
<UBadge v-if="lang.is_default" color="primary" variant="soft" size="xs">{{ t('languages.default_badge', 'Default') }}</UBadge>
|
|
77
77
|
</div>
|
|
78
78
|
<div class="flex items-center gap-1">
|
|
79
|
-
<UBadge :color="statusColor(lang.code)" variant="soft" size="xs">{{ statusLabel(lang.code) }}</UBadge>
|
|
79
|
+
<UBadge :data-cy="'translation-status-' + lang.code" :color="statusColor(lang.code)" variant="soft" size="xs">{{ statusLabel(lang.code) }}</UBadge>
|
|
80
80
|
<UTooltip :text="sourceText ? `${t('translations.translate_to', 'Translate to')} ${findLanguage(lang.code)?.nativeName || lang.name}` : t('translations.no_source', 'No source available')">
|
|
81
81
|
<UButton
|
|
82
82
|
icon="i-heroicons-sparkles"
|
|
@@ -111,6 +111,7 @@
|
|
|
111
111
|
<!-- Single textarea -->
|
|
112
112
|
<div v-else :ref="el => activeTextareaWrapper = el as HTMLElement">
|
|
113
113
|
<UTextarea
|
|
114
|
+
:data-cy="'translation-textarea-' + lang.code"
|
|
114
115
|
v-model="editValue"
|
|
115
116
|
:rows="3"
|
|
116
117
|
autofocus
|
|
@@ -167,10 +168,10 @@
|
|
|
167
168
|
|
|
168
169
|
<!-- Actions row -->
|
|
169
170
|
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
|
170
|
-
<UButton size="xs" :loading="saving === lang.code" @click="saveTranslation(lang.code)">
|
|
171
|
+
<UButton :data-cy="'save-translation-btn-' + lang.code" size="xs" :loading="saving === lang.code" @click="saveTranslation(lang.code)">
|
|
171
172
|
{{ t('translations.save', 'Save') }}
|
|
172
173
|
</UButton>
|
|
173
|
-
<UButton size="xs" color="neutral" variant="ghost" @click="editingLang = null">
|
|
174
|
+
<UButton :data-cy="'cancel-translation-btn-' + lang.code" size="xs" color="neutral" variant="ghost" @click="editingLang = null">
|
|
174
175
|
{{ t('translations.cancel', 'Cancel') }}
|
|
175
176
|
</UButton>
|
|
176
177
|
<div class="ml-auto flex items-center gap-1.5">
|
|
@@ -194,6 +195,7 @@
|
|
|
194
195
|
<!-- Plural display -->
|
|
195
196
|
<div
|
|
196
197
|
v-if="getTranslationValue(lang.code) && getPluralForms(lang.code).length > 1"
|
|
198
|
+
:data-cy="'translation-value-' + lang.code"
|
|
197
199
|
class="cursor-pointer group"
|
|
198
200
|
@click="startEdit(lang)"
|
|
199
201
|
>
|
|
@@ -216,11 +218,13 @@
|
|
|
216
218
|
<!-- Single value display -->
|
|
217
219
|
<p
|
|
218
220
|
v-else-if="getTranslationValue(lang.code)"
|
|
221
|
+
:data-cy="'translation-value-' + lang.code"
|
|
219
222
|
class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 whitespace-pre-wrap px-2 py-1.5 rounded hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors"
|
|
220
223
|
@click="startEdit(lang)"
|
|
221
224
|
>{{ getTranslationValue(lang.code) }}</p>
|
|
222
225
|
<button
|
|
223
226
|
v-else
|
|
227
|
+
:data-cy="'translation-value-' + lang.code"
|
|
224
228
|
class="text-sm text-gray-400 italic hover:text-primary-500 transition-colors px-2 py-1.5"
|
|
225
229
|
@click="startEdit(lang)"
|
|
226
230
|
>
|
|
@@ -294,7 +298,7 @@
|
|
|
294
298
|
</div>
|
|
295
299
|
</template>
|
|
296
300
|
<template v-else>
|
|
297
|
-
<p v-if="keyData.description" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer hover:text-primary-500 transition-colors" @click="startEditDescription">
|
|
301
|
+
<p data-cy="key-description" v-if="keyData.description" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer hover:text-primary-500 transition-colors" @click="startEditDescription">
|
|
298
302
|
{{ keyData.description }}
|
|
299
303
|
</p>
|
|
300
304
|
<button v-else class="text-sm text-gray-400 italic hover:text-primary-500 transition-colors" @click="startEditDescription">
|
|
@@ -329,12 +333,12 @@
|
|
|
329
333
|
<template #header>
|
|
330
334
|
<div class="flex items-center gap-2">
|
|
331
335
|
<UIcon name="i-heroicons-code-bracket" class="text-gray-400 shrink-0" />
|
|
332
|
-
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
|
336
|
+
<p data-cy="history-section" class="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
|
333
337
|
{{ keyData.usages.length }} {{ keyData.usages.length > 1 ? t('translations.references_plural', 'references') : t('translations.references', 'reference') }}
|
|
334
338
|
</p>
|
|
335
339
|
</div>
|
|
336
340
|
</template>
|
|
337
|
-
<div class="space-y-3">
|
|
341
|
+
<div data-cy="key-usages" class="space-y-3">
|
|
338
342
|
<div v-for="(usage, i) in keyData.usages" :key="i" class="text-xs">
|
|
339
343
|
<p class="font-mono text-gray-600 dark:text-gray-400 truncate" :title="usage.file_path">{{ usage.file_path }}</p>
|
|
340
344
|
<div class="flex items-center gap-2 text-gray-400 mt-0.5">
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<!-- Header -->
|
|
4
4
|
<div class="flex items-center justify-between mb-5">
|
|
5
5
|
<div>
|
|
6
|
-
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('translations.title', 'Translations') }}</h1>
|
|
6
|
+
<h1 data-cy="translations-title" class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('translations.title', 'Translations') }}</h1>
|
|
7
7
|
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
|
|
8
8
|
{{ data?.total || 0 }} {{ t('translations.keys_count', 'keys') }} · {{ languages.length }} {{ t('translations.langs_count', 'languages') }}
|
|
9
9
|
</p>
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
>
|
|
20
20
|
{{ t('translations.translate_all', 'Translate all') }} ({{ filterLangs[0].toUpperCase() }})
|
|
21
21
|
</UButton>
|
|
22
|
-
<UButton v-if="userCanManage" icon="i-heroicons-plus" @click="showAddKey = true">{{ t('translations.add_key', 'New key') }}</UButton>
|
|
22
|
+
<UButton v-if="userCanManage" data-cy="new-key-btn" icon="i-heroicons-plus" @click="showAddKey = true">{{ t('translations.add_key', 'New key') }}</UButton>
|
|
23
23
|
</div>
|
|
24
24
|
</div>
|
|
25
25
|
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
<div class="flex flex-col sm:flex-row gap-3 mb-5">
|
|
28
28
|
<UInput
|
|
29
29
|
v-model="search"
|
|
30
|
+
data-cy="translations-search"
|
|
30
31
|
icon="i-heroicons-magnifying-glass"
|
|
31
32
|
:placeholder="t('translations.search', 'Search for a key...')"
|
|
32
33
|
class="flex-1"
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
<button
|
|
49
50
|
v-for="s in statusFilters"
|
|
50
51
|
:key="s.value"
|
|
52
|
+
:data-cy="'filter-' + s.value"
|
|
51
53
|
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all"
|
|
52
54
|
:class="filterStatus === s.value
|
|
53
55
|
? `${s.activeBg} ${s.activeText} border-transparent`
|
|
@@ -129,7 +131,7 @@
|
|
|
129
131
|
<!-- Add Key modal -->
|
|
130
132
|
<UModal v-model:open="showAddKey" :title="t('translations.add_key_title', 'New translation key')">
|
|
131
133
|
<template #body>
|
|
132
|
-
<div class="space-y-4">
|
|
134
|
+
<div data-cy="add-key-modal" class="space-y-4">
|
|
133
135
|
<UFormField :label="t('translations.key_label', 'Key')" :hint="t('translations.key_hint', 'Example: home.title or nav.menu.about')" required>
|
|
134
136
|
<UInput v-model="newKey.key" placeholder="home.title" class="w-full font-mono" />
|
|
135
137
|
</UFormField>
|
|
@@ -140,8 +142,8 @@
|
|
|
140
142
|
</template>
|
|
141
143
|
<template #footer>
|
|
142
144
|
<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
|
+
<UButton data-cy="add-key-cancel-btn" variant="ghost" color="neutral" @click="showAddKey = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
146
|
+
<UButton data-cy="add-key-create-btn" :loading="addingKey" @click="addKey">{{ t('common.create', 'Create') }}</UButton>
|
|
145
147
|
</div>
|
|
146
148
|
</template>
|
|
147
149
|
</UModal>
|