i18n-dashboard 0.4.3 → 0.6.2

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
@@ -30,10 +30,11 @@
30
30
 
31
31
  ### Scan & Sync
32
32
  - **Source scan (local)** — browse your file system with the built-in folder picker, detect all `$t()`, `t()`, `<i18n-t>`, `v-t`, and `<i18n>` block usages across `.vue`, `.ts`, `.js` files
33
- - **Source scan (Git)** — clone any Git repository (public or private) using a URL, branch, and access token, then scan the source files without needing a local checkout
34
- - **Source scan (URL)** — fetch `en.json`, `fr.json`… from any remote URL and import all keys
35
- - **Sync** — import existing `.json` locale files (local path or remote URL) into the database
33
+ - **Source scan (git)** — provide a git repository URL (+ optional branch and access token); the dashboard clones the repo, scans source files, and imports locale files in one step
34
+ - **Sync (local)** — import existing `.json` locale files from a local path into the database
35
+ - **Sync (git)** — clone the configured git repository and import locale JSON files; only fills missing or empty translations never overwrites existing values
36
36
  - **Unused key detection** — keys not found in source files are automatically flagged
37
+ - **Conflict-safe import** — sync and scan never overwrite a translation that already has a value in the database
37
38
 
38
39
  ### Advanced Formats
39
40
  *(enable per project in Settings)*
@@ -43,7 +44,11 @@
43
44
  - **Snippet generator** — generates a ready-to-paste `createI18n()` configuration block
44
45
 
45
46
  ### Projects
46
- - **Inline settings** — edit project name, root path, source URLs, Git repository info, locales folder, key separator, color, and description directly from the project settings page
47
+ - **3-step creation wizard** — Source Info Languages: add a local path or git repo, auto-detect project name, locales folder and existing languages, then pick languages with the built-in BCP 47 picker
48
+ - **Auto-detect on creation** — reads `package.json` for the project name and scans the locale folder to pre-fill languages; works with both local paths and git repos
49
+ - **Auto-scan on creation** — a scan is automatically triggered after project creation so keys are immediately available
50
+ - **Git repository per project** — configure a git URL, branch, and optional access token; used for scan and sync operations without requiring a local clone
51
+ - **Inline settings** — edit project name, root path, git repo, locales folder, key separator, color, and description directly from the project settings page
47
52
  - **Folder browser** — navigate your file system visually to pick the project root path
48
53
  - **Project snapshot** — export a complete backup (config + languages + all keys + translations) as a single JSON file, import it on any other instance (merge or replace mode)
49
54
 
@@ -306,7 +311,7 @@ The `{count}` and `{n}` parameters are always available implicitly.
306
311
 
307
312
  ### Scan
308
313
 
309
- Click **Scan project** in the sidebar to open the scan modal. Three modes are available:
314
+ Click **Scan project** in the sidebar or on any project card to open the scan modal.
310
315
 
311
316
  **Local mode** — browse your file system with the folder picker and scan `.vue`, `.ts`, `.js` files for:
312
317
  - `$t('key')`, `$tc()`, `$te()`, `$tm()`
@@ -315,13 +320,11 @@ Click **Scan project** in the sidebar to open the scan modal. Three modes are av
315
320
  - `v-t="'key'"`
316
321
  - `<i18n>` SFC blocks
317
322
 
318
- **Git mode** — provide a repository URL, branch (default: `main`), and a personal access token. The dashboard clones the repo (shallow, depth 1) into a temp directory, scans the source files exactly like local mode, then cleans up automatically. Useful for CI environments or when you don't have a local checkout.
323
+ **Git mode** — enter a git repository URL (and optionally a branch and access token); the scanner clones the repo (shallow, depth 1), scans all source files, and also imports locale JSON files to populate missing translations in one step. Useful for CI environments or when you don't have a local checkout.
319
324
 
320
325
  > Required token permission: **Contents → Read** (GitHub fine-grained PAT) or **repo** scope (classic PAT).
321
326
 
322
- **URL mode** enter the base URL of your app; the scanner fetches each configured locale file (`/locale/en.json`, `/locale/fr.json`…) and imports all keys it finds.
323
-
324
- Results (keys found, new keys, unused keys, files scanned) are displayed inline.
327
+ Results displayed inline: keys found, new keys, unused keys, files scanned, languages added, translations imported.
325
328
 
326
329
  ### Settings
327
330
 
@@ -577,8 +580,10 @@ POST /api/translations/batch-translate # Auto-translate all missing for a lang
577
580
  ### Scan & Sync
578
581
 
579
582
  ```http
580
- POST /api/scan # body: { project_id, mode: 'local'|'git'|'url', root_path?, url?, git_url?, git_branch?, git_token? }
581
- POST /api/sync # body: { project_id }
583
+ POST /api/scan # body: { project_id, mode: 'local'|'git', root_path?, git_url?, git_branch?, git_token? }
584
+ POST /api/sync # body: { project_id } — uses project's configured git repo or local path
585
+ POST /api/projects/detect # body: { root_path?, git_url?, git_branch?, git_token? }
586
+ GET /api/projects/check-name?name=&exclude_id= # Availability check
582
587
  ```
583
588
 
584
589
  ### Project Snapshot
@@ -39,6 +39,13 @@
39
39
  "user.save_password": "Save",
40
40
  "user.password_changed": "Password changed",
41
41
  "user.passwords_mismatch": "Passwords do not match",
42
+ "keys.created": "Key created",
43
+ "keys.deleted": "Key deleted",
44
+ "keys.description_updated": "Description updated",
45
+ "keys.version_restored": "Version restored",
46
+ "keys.translate_done": "Auto-translate complete",
47
+ "keys.translated": "translated",
48
+ "keys.skipped": "skipped",
42
49
  "translations.title": "Translations",
43
50
  "translations.keys_count": "keys",
44
51
  "translations.langs_count": "languages",
@@ -86,6 +93,8 @@
86
93
  "status.rejected": "Rejected",
87
94
  "status.missing": "Missing",
88
95
  "status.unused": "Unused",
96
+ "review.marked_reviewed": "Marked as reviewed",
97
+ "review.translations_reviewed": "translation(s) marked as reviewed",
89
98
  "review.title": "Review queue",
90
99
  "review.approve_all": "Approve all",
91
100
  "review.empty_title": "No translations pending",
@@ -99,12 +108,17 @@
99
108
  "review.mark_all_reviewed": "Mark all as reviewed",
100
109
  "review.reject": "Reject",
101
110
  "review.mark_reviewed": "Mark as reviewed",
111
+ "languages.deleted": "Language deleted",
112
+ "languages.translating": "Translating",
113
+ "languages.translate_done": "Translation complete",
114
+ "languages.with_errors": "with errors",
102
115
  "languages.title": "Languages",
103
116
  "languages.subtitle": "Manage project languages",
104
117
  "languages.add": "Add a language",
105
118
  "languages.none": "No language configured",
106
119
  "languages.none_hint": "Add languages to start translating.",
107
120
  "languages.default_badge": "Default",
121
+ "settings.saved": "Settings saved",
108
122
  "settings.title": "Settings",
109
123
  "settings.save": "Save",
110
124
  "settings.root_path": "Root path",
@@ -167,6 +181,12 @@
167
181
  "formats.number_title": "Number formats",
168
182
  "formats.datetime_title": "Date & time formats",
169
183
  "formats.modifiers_title": "Modifiers",
184
+ "users.created": "User created",
185
+ "users.invitation_sent": "Invitation sent to",
186
+ "users.access_updated": "Access updated",
187
+ "users.deactivated": "User deactivated",
188
+ "users.reactivated": "User reactivated",
189
+ "users.deleted": "User deleted",
170
190
  "users.title": "Users",
171
191
  "users.subtitle": "Manage dashboard access",
172
192
  "users.all_title": "All users",
@@ -207,6 +227,9 @@
207
227
  "users.delete_warning": "This action is irreversible. The account and all associated data will be permanently deleted.",
208
228
  "users.global_access_all": "All projects",
209
229
  "users.no_access": "No access",
230
+ "projects.created": "Project added",
231
+ "projects.updated": "Project updated",
232
+ "projects.deleted": "Project deleted",
210
233
  "projects.subtitle": "Manage your translation projects",
211
234
  "projects.add": "Add a project",
212
235
  "projects.none": "No project",
@@ -214,7 +237,11 @@
214
237
  "projects.add_first": "Add my first project",
215
238
  "projects.keys_stat": "keys",
216
239
  "projects.separator": "separator",
217
- "projects.scan_requires_local": "Requires a local path",
240
+ "projects.git_repos_count": "git repo(s)",
241
+ "projects.more_repos": "more repo(s)",
242
+ "projects.no_description": "No description",
243
+ "projects.name_taken": "This name is already taken",
244
+ "projects.scan_requires_local": "Requires a local path or git repo",
218
245
  "projects.scan_tooltip": "Scan source files to detect translation keys",
219
246
  "projects.sync_requires_source": "Requires a local path or remote URL",
220
247
  "projects.sync_tooltip": "Import locale JSON files into the database",
@@ -249,6 +276,31 @@
249
276
  "projects.color_pink": "Pink",
250
277
  "projects.color_yellow": "Yellow",
251
278
  "projects.color_gray": "Gray",
279
+ "projects.git_repo_title": "Git repository",
280
+ "projects.step_source": "Source",
281
+ "projects.step_info": "Info",
282
+ "projects.step_languages": "Languages",
283
+ "projects.step_source_hint": "Enter a local path and/or a git repository to auto-detect project configuration.",
284
+ "projects.detecting": "Detecting...",
285
+ "projects.detect_next": "Detect & Next",
286
+ "projects.creating_languages": "Adding languages...",
287
+ "projects.scanning_files": "Scanning source files...",
288
+ "projects.syncing_translations": "Syncing translations...",
289
+ "projects.no_source_hint": "Enter a local path or git repo URL to continue.",
290
+ "projects.step_languages_hint": "Add the languages for this project. The first one will be set as default. You can add more later.",
291
+ "languages.selected": "Selected languages",
292
+ "languages.set_as_default": "Set as default",
293
+ "projects.git_repos_title": "Git repositories",
294
+ "projects.git_token_set": "token set",
295
+ "projects.git_repo_url_label": "Repository URL",
296
+ "projects.git_repo_branch_label": "Branch",
297
+ "projects.git_repo_name_label": "Name (optional)",
298
+ "projects.git_repo_name_placeholder": "e.g. frontend",
299
+ "projects.git_token_label": "Access token (optional)",
300
+ "projects.git_token_placeholder": "ghp_...",
301
+ "projects.git_add_btn": "Add repository",
302
+ "profile.updated": "Account updated",
303
+ "profile.access_updated": "Access updated",
252
304
  "profile.member_since": "Member since",
253
305
  "profile.last_login": "Last login",
254
306
  "profile.edit_account": "Edit account",
@@ -262,8 +314,11 @@
262
314
  "profile.name_label": "Full name",
263
315
  "profile.name_placeholder": "John Doe",
264
316
  "profile.total_translations": "Total translations",
265
- "profile.this_month": "This month",
266
- "profile.this_week": "This week",
317
+ "profile.period_1d": "Last 24 hours",
318
+ "profile.period_7d": "Last 7 days",
319
+ "profile.period_30d": "Last 30 days",
320
+ "profile.period_365d": "Last year",
321
+ "profile.period_all": "Since account creation",
267
322
  "profile.general": "General",
268
323
  "profile.name_email_required": "Name and email are required",
269
324
  "dashboard.title": "Dashboard",
@@ -290,6 +345,7 @@
290
345
  "dashboard.languages_coverage": "Language coverage",
291
346
  "dashboard.no_languages": "No language configured",
292
347
  "dashboard.missing": "missing",
348
+ "common.errors": "errors",
293
349
  "common.cancel": "Cancel",
294
350
  "common.save": "Save",
295
351
  "common.delete": "Delete",
@@ -301,6 +357,8 @@
301
357
  "common.create": "Create",
302
358
  "common.or": "or",
303
359
  "common.irreversible": "This action is irreversible.",
360
+ "common.back": "Back",
361
+ "common.next": "Next",
304
362
  "common.just_now": "just now",
305
363
  "common.ago": "ago",
306
364
  "login.title": "Log in",
@@ -361,15 +419,28 @@
361
419
  "pathpicker.title": "Select a folder",
362
420
  "pathpicker.empty": "No subfolder",
363
421
  "pathpicker.select": "Select this folder",
422
+ "sync.toast_title": "Sync",
423
+ "sync.added": "added",
424
+ "sync.updated": "updated",
425
+ "sync.total": "total",
426
+ "scan.toast_title": "Scan",
427
+ "scan.langs_added": "language(s) added",
364
428
  "scan.modal_title": "Scan project",
365
429
  "scan.mode_local": "Local",
366
- "scan.mode_url": "Via URL",
430
+ "scan.mode_git": "Git repo",
367
431
  "scan.local_hint": "Select the root folder of your Vue.js project. The scanner will detect all $t(), t(), <i18n-t> and v-t usages.",
368
432
  "scan.local_path_label": "Project root folder",
369
- "scan.url_hint": "Enter the base URL of your app. The scanner will fetch each configured locale file (en.json, fr.json…) and import all keys.",
370
- "scan.url_label": "Base URL",
371
- "scan.url_hint2": "Example: https://my-app.com",
372
- "scan.url_no_langs": "No languages configured yet. Add languages first so the scanner knows which locale files to fetch.",
433
+ "scan.git_hint": "Enter the URL of a Git repository. The dashboard will clone it and scan source files for translation keys.",
434
+ "scan.git_url_label": "Repository URL",
435
+ "scan.git_url_hint": "Example: https://github.com/user/my-app",
436
+ "scan.git_branch_label": "Branch",
437
+ "scan.git_branch_hint": "Leave empty to use the default branch",
438
+ "scan.git_token_label": "Access token",
439
+ "scan.git_token_hint": "For private repositories",
440
+ "scan.git_save": "Save this repository to the project",
441
+ "scan.git_saved": "Saved repositories",
442
+ "scan.git_repo_name_label": "Repository name",
443
+ "scan.git_repo_name_placeholder": "My App",
373
444
  "scan.results": "Results",
374
445
  "scan.keys_found": "keys found",
375
446
  "scan.keys_added": "new keys",
@@ -377,19 +448,5 @@
377
448
  "scan.files_scanned": "files scanned",
378
449
  "scan.errors": "errors",
379
450
  "scan.run": "Scan",
380
- "scan.mode_git": "Git",
381
- "scan.git_hint": "Enter the Git repository URL. The scanner will clone it and detect all translation key usages in source files.",
382
- "scan.git_url_label": "Repository URL",
383
- "scan.git_url_hint": "Example: https://github.com/user/repo.git",
384
- "scan.git_branch_label": "Branch",
385
- "scan.git_branch_hint": "Leave empty to use main",
386
- "scan.git_token_label": "Access token",
387
- "scan.git_token_hint": "Required for private repositories",
388
- "scan.git_token_placeholder": "ghp_xxxxxxxxxxxx",
389
- "settings.git_url": "Git repository URL",
390
- "settings.git_url_hint": "Used for Git scan mode",
391
- "settings.git_branch": "Git branch",
392
- "settings.git_branch_hint": "Default: main",
393
- "settings.git_token": "Git access token",
394
- "settings.git_token_hint": "For private repositories"
451
+ "scan.translations_synced": "translations imported"
395
452
  }
@@ -0,0 +1,41 @@
1
+ <template>
2
+ <div class="space-y-2">
3
+ <div class="grid grid-cols-2 gap-2">
4
+ <UFormField :label="t('projects.git_repo_url_label', 'Repository URL')" class="col-span-2">
5
+ <UInput v-model="local.url" class="w-full" placeholder="https://github.com/org/repo.git" @input="emitCurrent" />
6
+ </UFormField>
7
+ <UFormField :label="t('projects.git_repo_branch_label', 'Branch')" class="col-span-2">
8
+ <UInput v-model="local.branch" class="w-full" placeholder="main" @input="emitCurrent" />
9
+ </UFormField>
10
+ </div>
11
+ <UFormField :label="t('projects.git_token_label', 'Access token (optional)')">
12
+ <UInput v-model="local.token" type="password" class="w-full" :placeholder="t('projects.git_token_placeholder', 'ghp_...')" @input="emitCurrent" />
13
+ </UFormField>
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import type { GitRepo } from '~/interfaces/project.interface'
19
+
20
+ const { t } = useT()
21
+
22
+ const props = defineProps<{
23
+ modelValue: GitRepo | null | undefined
24
+ }>()
25
+
26
+ const emit = defineEmits<{
27
+ 'update:modelValue': [value: GitRepo | null]
28
+ }>()
29
+
30
+ const local = reactive<GitRepo>({ url: '', branch: '', token: '' })
31
+
32
+ watch(() => props.modelValue, (val) => {
33
+ local.url = val?.url ?? ''
34
+ local.branch = val?.branch ?? ''
35
+ local.token = val?.token ?? ''
36
+ }, { immediate: true })
37
+
38
+ function emitCurrent() {
39
+ emit('update:modelValue', local.url.trim() ? { ...local } : null)
40
+ }
41
+ </script>
@@ -0,0 +1,145 @@
1
+ <template>
2
+ <div class="space-y-3">
3
+ <!-- Search -->
4
+ <UInput
5
+ v-model="search"
6
+ :placeholder="t('onboarding.languages_search', 'Search for a language...')"
7
+ icon="i-heroicons-magnifying-glass"
8
+ class="w-full"
9
+ />
10
+
11
+ <!-- List -->
12
+ <div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
13
+ <div class="max-h-48 overflow-y-auto">
14
+ <button
15
+ v-for="lang in filteredList"
16
+ :key="lang.code"
17
+ 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"
18
+ :class="isSelected(lang.code) ? 'opacity-40 cursor-default' : 'text-gray-700 dark:text-gray-300'"
19
+ :disabled="isSelected(lang.code)"
20
+ @click="add(lang)"
21
+ >
22
+ <span class="font-mono text-xs text-gray-400 w-14 shrink-0">{{ lang.code }}</span>
23
+ <span class="flex-1">{{ lang.nativeName }}</span>
24
+ <span class="text-xs text-gray-400 shrink-0">{{ lang.name }}</span>
25
+ <UIcon v-if="isSelected(lang.code)" name="i-heroicons-check" class="text-primary-500 shrink-0" />
26
+ </button>
27
+ <div v-if="!filteredList.length" class="px-3 py-4 text-sm text-center text-gray-400">
28
+ {{ t('languages.none_found', 'No language found') }}
29
+ </div>
30
+ </div>
31
+
32
+ <!-- Custom BCP-47 -->
33
+ <div
34
+ v-if="search && isValidBcp47(search) && !filteredList.find(l => l.code.toLowerCase() === search.toLowerCase())"
35
+ class="border-t border-gray-200 dark:border-gray-700"
36
+ >
37
+ <button
38
+ class="w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left hover:bg-amber-50 dark:hover:bg-amber-900/20 text-gray-500 dark:text-gray-400 transition-colors"
39
+ @click="addCustom(search)"
40
+ >
41
+ <UIcon name="i-heroicons-plus-circle" class="shrink-0 text-amber-500" />
42
+ <span class="flex-1">{{ t('languages.use_code', 'Use code') }} <code class="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ search }}</code></span>
43
+ <UBadge size="xs" color="warning" variant="soft">BCP 47</UBadge>
44
+ </button>
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Selected languages -->
49
+ <div v-if="modelValue.length" class="space-y-1.5">
50
+ <p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('languages.selected', 'Selected languages') }}</p>
51
+ <div class="flex flex-wrap gap-2">
52
+ <div
53
+ v-for="lang in modelValue"
54
+ :key="lang.code"
55
+ class="flex items-center gap-1.5 bg-gray-100 dark:bg-gray-800 rounded-lg px-2 py-1"
56
+ >
57
+ <span class="text-xs font-mono font-medium text-gray-700 dark:text-gray-300">{{ lang.code }}</span>
58
+ <UBadge
59
+ v-if="lang.is_default"
60
+ size="xs"
61
+ color="primary"
62
+ variant="soft"
63
+ class="cursor-pointer"
64
+ @click="setDefault(lang.code)"
65
+ >{{ t('languages.default_badge', 'Default') }}</UBadge>
66
+ <button
67
+ v-else
68
+ class="text-xs text-gray-400 hover:text-primary-500 transition-colors"
69
+ :title="t('languages.set_as_default', 'Set as default')"
70
+ @click="setDefault(lang.code)"
71
+ >
72
+ <UIcon name="i-heroicons-star" class="text-xs" />
73
+ </button>
74
+ <button class="text-gray-300 hover:text-red-500 transition-colors" @click="remove(lang.code)">
75
+ <UIcon name="i-heroicons-x-mark" class="text-xs" />
76
+ </button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </template>
82
+
83
+ <script setup lang="ts">
84
+ import { LANGUAGES } from '~/consts/languages.const'
85
+
86
+ const { t } = useT()
87
+
88
+ const props = defineProps<{
89
+ modelValue: Array<{ code: string; name: string; is_default: boolean }>
90
+ }>()
91
+
92
+ const emit = defineEmits<{
93
+ 'update:modelValue': [value: Array<{ code: string; name: string; is_default: boolean }>]
94
+ }>()
95
+
96
+ const search = ref('')
97
+
98
+ const filteredList = computed(() => {
99
+ const q = search.value.toLowerCase()
100
+ const list = q
101
+ ? LANGUAGES.filter(l =>
102
+ l.code.toLowerCase().includes(q)
103
+ || l.name.toLowerCase().includes(q)
104
+ || l.nativeName.toLowerCase().includes(q),
105
+ )
106
+ : LANGUAGES
107
+ return list
108
+ })
109
+
110
+ function isSelected(code: string) {
111
+ return props.modelValue.some(l => l.code === code)
112
+ }
113
+
114
+ function isValidBcp47(code: string): boolean {
115
+ return /^[a-z]{2,8}(-[A-Za-z0-9]{1,8})*$/i.test(code) && code.length >= 2
116
+ }
117
+
118
+ function add(lang: { code: string; name: string; nativeName: string }) {
119
+ if (isSelected(lang.code)) return
120
+ const isFirst = props.modelValue.length === 0
121
+ emit('update:modelValue', [...props.modelValue, { code: lang.code, name: lang.name, is_default: isFirst }])
122
+ search.value = ''
123
+ }
124
+
125
+ function addCustom(code: string) {
126
+ const normalized = code.trim()
127
+ if (isSelected(normalized)) return
128
+ const isFirst = props.modelValue.length === 0
129
+ emit('update:modelValue', [...props.modelValue, { code: normalized, name: normalized, is_default: isFirst }])
130
+ search.value = ''
131
+ }
132
+
133
+ function remove(code: string) {
134
+ const updated = props.modelValue.filter(l => l.code !== code)
135
+ // If we removed the default, set the first one as default
136
+ if (updated.length && !updated.some(l => l.is_default)) {
137
+ updated[0].is_default = true
138
+ }
139
+ emit('update:modelValue', updated)
140
+ }
141
+
142
+ function setDefault(code: string) {
143
+ emit('update:modelValue', props.modelValue.map(l => ({ ...l, is_default: l.code === code })))
144
+ }
145
+ </script>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <UModal v-model:open="open" :title="t('scan.modal_title', 'Scan project')" :ui="{ width: 'sm:max-w-lg' }">
2
+ <UModal v-model:open="open" :title="t('scan.modal_title', 'Scan project')" :ui="{ width: '48rem' }">
3
3
  <template #body>
4
4
  <div class="space-y-5">
5
5
 
@@ -29,24 +29,16 @@
29
29
  </UFormField>
30
30
  </div>
31
31
 
32
- <!-- URL mode -->
33
- <div v-if="mode === 'url'" class="space-y-3">
32
+ <!-- Git mode -->
33
+ <div v-if="mode === 'git'" class="space-y-4">
34
34
  <p class="text-sm text-gray-500 dark:text-gray-400">
35
- {{ t('scan.url_hint', 'Enter the base URL of your app. The scanner will fetch each configured locale file (en.json, fr.json…) and import all keys it finds.') }}
35
+ {{ t('scan.git_hint', 'Clone a Git repository and scan source files for translation keys.') }}
36
36
  </p>
37
- <UFormField :label="t('scan.url_label', 'Base URL')" :hint="t('scan.url_hint2', 'First configured URL is used by default')">
38
- <UInput v-model="remoteUrl" class="w-full" placeholder="https://my-app.com" />
39
- </UFormField>
40
- <div v-if="!project?.languages?.length" class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
41
- <UIcon name="i-heroicons-exclamation-triangle" class="shrink-0 mt-0.5" />
42
- {{ t('scan.url_no_langs', 'No languages configured on this project yet. Add languages first so the scanner knows which locale files to fetch.') }}
43
- </div>
44
- <div v-else class="flex flex-wrap gap-1.5">
45
- <UBadge v-for="lang in project.languages" :key="lang.code" color="neutral" variant="soft" size="xs">
46
- <UIcon name="i-heroicons-document-text" class="mr-1 opacity-60" />
47
- {{ lang.code }}.json
48
- </UBadge>
49
- </div>
37
+ <GitRepoManager v-model="gitRepo" />
38
+ <label class="flex items-center gap-2 cursor-pointer">
39
+ <UToggle v-model="saveRepo" size="sm" />
40
+ <span class="text-sm text-gray-600 dark:text-gray-400">{{ t('scan.git_save', 'Save this repository to the project') }}</span>
41
+ </label>
50
42
  </div>
51
43
 
52
44
  <!-- Results -->
@@ -69,6 +61,14 @@
69
61
  <p class="text-xl font-bold text-gray-900 dark:text-white">{{ result.scannedFiles }}</p>
70
62
  <p class="text-xs text-gray-400">{{ t('scan.files_scanned', 'files scanned') }}</p>
71
63
  </div>
64
+ <div v-if="result.langsAdded" class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
65
+ <p class="text-xl font-bold text-primary-600 dark:text-primary-400">{{ result.langsAdded }}</p>
66
+ <p class="text-xs text-gray-400">{{ t('scan.langs_added', 'language(s) added') }}</p>
67
+ </div>
68
+ <div v-if="result.translationsAdded !== undefined" class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center col-span-2">
69
+ <p class="text-xl font-bold text-green-600 dark:text-green-400">{{ result.translationsAdded }}</p>
70
+ <p class="text-xs text-gray-400">{{ t('scan.translations_synced', 'translations imported') }}</p>
71
+ </div>
72
72
  </div>
73
73
  <p v-if="result.errors?.length" class="text-xs text-red-500">
74
74
  {{ result.errors.length }} {{ t('scan.errors', 'errors') }}
@@ -84,7 +84,7 @@
84
84
  <UButton color="neutral" variant="ghost" @click="open = false">{{ t('common.cancel', 'Cancel') }}</UButton>
85
85
  <UButton
86
86
  :loading="loading"
87
- :disabled="mode === 'local' ? !localPath : !remoteUrl"
87
+ :disabled="mode === 'local' ? !localPath : !gitRepo?.url"
88
88
  icon="i-heroicons-magnifying-glass"
89
89
  @click="runScan"
90
90
  >
@@ -96,27 +96,30 @@
96
96
  </template>
97
97
 
98
98
  <script setup lang="ts">
99
+ import type { GitRepo } from '~/interfaces/project.interface'
100
+
99
101
  const { t } = useT()
100
102
 
101
103
  const props = defineProps<{
102
104
  projectId: number
103
- project?: { languages?: { code: string; name: string }[]; root_path?: string; source_url?: string }
105
+ project?: { languages?: { code: string; name: string }[]; root_path?: string; git_repo?: GitRepo | null }
104
106
  }>()
105
107
 
106
108
  const emit = defineEmits<{ done: [] }>()
107
109
 
108
110
  const open = defineModel<boolean>('open', { default: false })
109
111
 
110
- const mode = ref<'local' | 'url'>('local')
112
+ const mode = ref<'local' | 'git'>('local')
111
113
  const localPath = ref(props.project?.root_path ?? '')
112
- const remoteUrl = ref(props.project?.source_url?.split(/[\n,]+/).map(u => u.trim()).filter(Boolean)[0] ?? '')
114
+ const gitRepo = ref<GitRepo | null>(null)
115
+ const saveRepo = ref(false)
113
116
  const loading = ref(false)
114
117
  const result = ref<any>(null)
115
118
  const error = ref('')
116
119
 
117
120
  const modes = computed(() => [
118
121
  { value: 'local', label: t('scan.mode_local', 'Local'), icon: 'i-heroicons-computer-desktop' },
119
- { value: 'url', label: t('scan.mode_url', 'Via URL'), icon: 'i-heroicons-globe-alt' },
122
+ { value: 'git', label: t('scan.mode_git', 'Git repo'), icon: 'i-heroicons-code-bracket' },
120
123
  ])
121
124
 
122
125
  watch(open, (val) => {
@@ -124,8 +127,9 @@ watch(open, (val) => {
124
127
  result.value = null
125
128
  error.value = ''
126
129
  localPath.value = props.project?.root_path ?? ''
127
- remoteUrl.value = props.project?.source_url?.split(/[\n,]+/).map(u => u.trim()).filter(Boolean)[0] ?? ''
128
- mode.value = props.project?.root_path ? 'local' : props.project?.source_url ? 'url' : 'local'
130
+ saveRepo.value = false
131
+ mode.value = props.project?.root_path ? 'local' : 'git'
132
+ gitRepo.value = props.project?.git_repo ? { ...props.project.git_repo } : null
129
133
  }
130
134
  })
131
135
 
@@ -134,13 +138,22 @@ async function runScan() {
134
138
  error.value = ''
135
139
  result.value = null
136
140
  try {
141
+ if (mode.value === 'git' && saveRepo.value && gitRepo.value?.url) {
142
+ await $fetch(`/api/projects/${props.projectId}`, {
143
+ method: 'PUT',
144
+ body: { git_repo: gitRepo.value },
145
+ })
146
+ }
147
+
137
148
  result.value = await $fetch('/api/scan', {
138
149
  method: 'POST',
139
150
  body: {
140
151
  project_id: props.projectId,
141
152
  mode: mode.value,
142
153
  root_path: mode.value === 'local' ? localPath.value : undefined,
143
- url: mode.value === 'url' ? remoteUrl.value : undefined,
154
+ git_url: mode.value === 'git' ? gitRepo.value?.url : undefined,
155
+ git_branch: mode.value === 'git' ? gitRepo.value?.branch : undefined,
156
+ git_token: mode.value === 'git' ? gitRepo.value?.token : undefined,
144
157
  },
145
158
  })
146
159
  emit('done')
@@ -143,8 +143,11 @@
143
143
  <!-- Status dot -->
144
144
  <UTooltip :text="statusLabel(lang.code)" :delay-duration="300">
145
145
  <span
146
- class="mt-1.5 w-2 h-2 rounded-full shrink-0"
147
- :class="[statusDot(lang.code), canClickStatus(lang.code) ? 'cursor-pointer' : 'cursor-default']"
146
+ class="mt-1.5 w-2 h-2 rounded-full shrink-0 transition-opacity"
147
+ :class="[
148
+ cyclingStatusLang === lang.code ? 'bg-gray-300 dark:bg-gray-600 animate-pulse cursor-wait' : statusDot(lang.code),
149
+ canClickStatus(lang.code) && cyclingStatusLang !== lang.code ? 'cursor-pointer' : 'cursor-default'
150
+ ]"
148
151
  @click="cycleStatus(lang.code)"
149
152
  />
150
153
  </UTooltip>
@@ -275,6 +278,8 @@ const editingCell = ref<string | null>(null)
275
278
  const editValues = ref<Record<string, string>>({})
276
279
  const saving = ref<string | null>(null)
277
280
  const translateLoading = ref<string | null>(null)
281
+ const cyclingStatusLang = ref<string | null>(null)
282
+ const deletingKey = ref(false)
278
283
  const showHistory = ref(false)
279
284
  const historyTranslationId = ref<number | null>(null)
280
285
  const textareaWrappers = ref<Record<string, HTMLElement | null>>({})
@@ -387,7 +392,7 @@ function canClickStatus(langCode: string): boolean {
387
392
 
388
393
  async function cycleStatus(langCode: string) {
389
394
  const tr = props.translationKey.translations[langCode]
390
- if (!tr?.value) return
395
+ if (!tr?.value || cyclingStatusLang.value) return
391
396
 
392
397
  const current = (tr.status as TRANSLATION_STATUS) || TRANSLATION_STATUS.DRAFT
393
398
  let next: TRANSLATION_STATUS
@@ -400,6 +405,7 @@ async function cycleStatus(langCode: string) {
400
405
  next = current === TRANSLATION_STATUS.DRAFT ? TRANSLATION_STATUS.REVIEWED : TRANSLATION_STATUS.DRAFT
401
406
  }
402
407
 
408
+ cyclingStatusLang.value = langCode
403
409
  try {
404
410
  await $fetch('/api/translations/status', {
405
411
  method: 'POST',
@@ -409,6 +415,8 @@ async function cycleStatus(langCode: string) {
409
415
  refreshNuxtData('project-stats')
410
416
  } catch (e: any) {
411
417
  toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
418
+ } finally {
419
+ cyclingStatusLang.value = null
412
420
  }
413
421
  }
414
422
 
@@ -529,6 +537,7 @@ const rowActions = computed(() => {
529
537
  })
530
538
 
531
539
  async function deleteKey() {
540
+ deletingKey.value = true
532
541
  try {
533
542
  await $fetch(`/api/keys/${props.translationKey.id}`, { method: 'DELETE' })
534
543
  toast.add({ title: t('translations.key_deleted', 'Key deleted'), color: 'success' })
@@ -536,6 +545,8 @@ async function deleteKey() {
536
545
  refreshNuxtData('project-stats')
537
546
  } catch (e: any) {
538
547
  toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
548
+ } finally {
549
+ deletingKey.value = false
539
550
  }
540
551
  }
541
552
  </script>