i18n-dashboard 0.6.5 → 0.9.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 CHANGED
@@ -54,6 +54,8 @@
54
54
 
55
55
  ### Users & Authentication
56
56
  - **Role-based access** — `Super Admin`, `Admin`, `Moderator`, `Translator` — per-project assignments
57
+ - **User search when adding to a project** — search existing users by name or email, select one and choose their role directly; or switch to the creation form for a brand new account
58
+ - **User activity profile** — view translation statistics per user with a configurable time period (last 24h, 7d, 30d, 1 year, or since account creation)
57
59
  - **Onboarding wizard** — guided setup on first launch
58
60
  - **Multi-language UI** — the dashboard interface itself is translatable
59
61
 
@@ -62,7 +64,10 @@
62
64
  - **Auto-migration** — schema is created and updated automatically on startup
63
65
  - **REST API** — full API for all operations, consume locale JSON from your Vue app
64
66
  - **CORS auto-detection** — multiple app URLs per project; all are checked for CORS on `/locale/[lang].json`
67
+ - **Global loading overlay** — a full-page loading screen prevents interaction before data is ready, including on direct page load (F5) for any route
65
68
  - **Dark mode** — system preference + manual toggle
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
66
71
 
67
72
  ---
68
73
 
@@ -615,6 +620,19 @@ DELETE /api/formats/modifiers/:id
615
620
  GET /api/formats/snippet?project_id=1 # Generate createI18n() config snippet
616
621
  ```
617
622
 
623
+ ### Users
624
+
625
+ ```http
626
+ GET /api/users # All users (super admin / global admin)
627
+ GET /api/users?project_id=1 # Members of a project
628
+ GET /api/users?exclude_project_id=1 # Users not yet in a project (for the add picker)
629
+ POST /api/users # Create user
630
+ PUT /api/users/:id # Update user (name, is_active, role)
631
+ PUT /api/users/:id/roles # Bulk-update role assignments across projects
632
+ DELETE /api/users/:id?project_id=1 # Remove user from project (or globally if super admin)
633
+ GET /api/users/:id/profile?period=30d # Full user profile with activity stats
634
+ ```
635
+
618
636
  ### Settings
619
637
 
620
638
  ```http
@@ -707,6 +725,7 @@ npx i18n-dashboard sync
707
725
  | [@vitalets/google-translate-api](https://github.com/vitalets/google-translate-api) | 9.x | Google Translate (free tier) |
708
726
  | [Commander.js](https://github.com/tj/commander.js) | 13.x | CLI |
709
727
  | [bcryptjs](https://github.com/dcodeIO/bcrypt.js) | 2.x | Password hashing |
728
+ | [Cypress](https://www.cypress.io/) | 13.x | E2E test suite |
710
729
 
711
730
  ---
712
731
 
@@ -201,6 +201,13 @@
201
201
  "users.project_members": "Project members",
202
202
  "users.none_in_project": "No members in this project",
203
203
  "users.add_user_title": "Add a user",
204
+ "users.add_to_project_title": "Add a user to the project",
205
+ "users.add_to_project": "Add to project",
206
+ "users.create_new_user": "Create a new user",
207
+ "users.back_to_select": "Back",
208
+ "users.search_placeholder": "Search by name or email…",
209
+ "users.no_match": "No user matches your search",
210
+ "users.all_already_members": "All users are already members of this project",
204
211
  "users.full_name": "Full name",
205
212
  "users.role_label": "Role",
206
213
  "users.project_label": "Project",
@@ -114,11 +114,26 @@ export function useKeys(options: {
114
114
  return Array.isArray(v) ? v[0] : v
115
115
  })
116
116
 
117
- const { data: keyData, pending: detailPending, refresh: detailRefresh } = useAsyncData(
118
- `key-${keyId.value ?? 'none'}`,
119
- () => keyId.value ? keyService.getKey(keyId.value) : Promise.resolve(null),
120
- { watch: [keyId] },
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 { data, pending, refresh } = useAsyncData<LanguageItem[]>(
42
- 'project-languages',
43
- () => languageService.getLanguages(currentProject.value?.id),
44
- { watch: [() => currentProject.value?.id], default: () => [] },
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 { data: profile, pending, refresh } = useAsyncData(
16
- () => targetId.value ? `user-profile-${targetId.value}-${period.value}` : `user-profile-${period.value}`,
17
- () => targetId.value
18
- ? profileService.getUserProfile(targetId.value, period.value)
19
- : profileService.getProfile(period.value),
20
- { watch: [targetId, period], server: false },
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 { data, pending, refresh } = useAsyncData(
9
- 'settings',
10
- () => settingsService.getSettings(),
11
- { default: () => ({} as Record<string, string>) },
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
 
@@ -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 { data, pending, refresh } = useAsyncData(
15
- scope === 'global' ? 'users-global' : 'users',
16
- () => userService.getUsers(usersQuery.value),
17
- { watch: [usersQuery], default: () => [] },
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
 
@@ -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.6.5",
3
+ "version": "0.9.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,9 @@
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"
19
21
  },
20
22
  "dependencies": {
21
23
  "@nuxt/ui": "^3.3.7",
@@ -84,6 +86,7 @@
84
86
  "license": "MIT",
85
87
  "devDependencies": {
86
88
  "@iconify-json/heroicons": "^1.2.3",
87
- "@types/node": "^25.3.5"
89
+ "@types/node": "^25.3.5",
90
+ "cypress": "^15.12.0"
88
91
  }
89
92
  }
package/pages/index.vue CHANGED
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="p-6">
2
+ <div data-cy="dashboard-page" class="p-6">
3
3
  <DashboardWidgetGrid />
4
4
  </div>
5
5
  </template>
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 apiAddress = document.location.origin
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>