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.
Files changed (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +715 -0
  3. package/app.vue +8 -0
  4. package/assets/css/main.css +21 -0
  5. package/assets/locales/en.json +380 -0
  6. package/bin/cli.mjs +279 -0
  7. package/components/LinkedKeyPicker.vue +135 -0
  8. package/components/PathPicker.vue +153 -0
  9. package/components/PluralEditor.vue +295 -0
  10. package/components/ScanModal.vue +153 -0
  11. package/components/TranslationHistoryModal.vue +66 -0
  12. package/components/TranslationRow.vue +541 -0
  13. package/components/dashboard/WidgetConfigModal.vue +121 -0
  14. package/components/dashboard/WidgetGrid.vue +190 -0
  15. package/components/dashboard/WidgetPicker.vue +75 -0
  16. package/components/dashboard/widgets/ActivityWidget.vue +109 -0
  17. package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
  18. package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
  19. package/components/dashboard/widgets/ReviewWidget.vue +150 -0
  20. package/components/dashboard/widgets/StatWidget.vue +133 -0
  21. package/composables/useAuth.ts +72 -0
  22. package/composables/useConfig.ts +14 -0
  23. package/composables/useDashboard.ts +89 -0
  24. package/composables/useFormats.ts +100 -0
  25. package/composables/useKeys.ts +231 -0
  26. package/composables/useLanguages.ts +221 -0
  27. package/composables/useProfile.ts +76 -0
  28. package/composables/useProject.ts +180 -0
  29. package/composables/useReview.ts +94 -0
  30. package/composables/useSettings.ts +30 -0
  31. package/composables/useStats.ts +16 -0
  32. package/composables/useT.ts +38 -0
  33. package/composables/useUsers.ts +101 -0
  34. package/composables/useWidgetData.ts +50 -0
  35. package/consts/commons.const.ts +6 -0
  36. package/consts/dashboard.const.ts +94 -0
  37. package/consts/languages.const.ts +223 -0
  38. package/enums/commons.enum.ts +7 -0
  39. package/i18n-dashboard.config.example.js +40 -0
  40. package/interfaces/commons.interface.ts +23 -0
  41. package/interfaces/job.interface.ts +10 -0
  42. package/interfaces/key.interface.ts +39 -0
  43. package/interfaces/languages.interface.ts +23 -0
  44. package/interfaces/project.interface.ts +9 -0
  45. package/interfaces/scan.interface.ts +12 -0
  46. package/interfaces/settings.interface.ts +4 -0
  47. package/interfaces/stat.interface.ts +30 -0
  48. package/interfaces/translation.interface.ts +11 -0
  49. package/interfaces/user.interface.ts +24 -0
  50. package/layouts/auth.vue +5 -0
  51. package/layouts/default.vue +327 -0
  52. package/middleware/auth.global.ts +26 -0
  53. package/nuxt.config.ts +66 -0
  54. package/package.json +89 -0
  55. package/pages/index.vue +5 -0
  56. package/pages/login.vue +74 -0
  57. package/pages/onboarding.vue +563 -0
  58. package/pages/projects/[id]/formats/datetime.vue +240 -0
  59. package/pages/projects/[id]/formats/modifiers.vue +194 -0
  60. package/pages/projects/[id]/formats/number.vue +250 -0
  61. package/pages/projects/[id]/index.vue +182 -0
  62. package/pages/projects/[id]/languages.vue +537 -0
  63. package/pages/projects/[id]/review.vue +109 -0
  64. package/pages/projects/[id]/settings.vue +515 -0
  65. package/pages/projects/[id]/translations/[keyId].vue +642 -0
  66. package/pages/projects/[id]/translations/index.vue +250 -0
  67. package/pages/projects/[id]/users.vue +276 -0
  68. package/pages/projects/index.vue +334 -0
  69. package/pages/users/[id]/profile.vue +421 -0
  70. package/pages/users/index.vue +345 -0
  71. package/plugins/loading.client.ts +3 -0
  72. package/plugins/ui-i18n.ts +6 -0
  73. package/server/api/auth/login.post.ts +28 -0
  74. package/server/api/auth/logout.post.ts +7 -0
  75. package/server/api/auth/me.get.ts +11 -0
  76. package/server/api/auth/me.put.ts +31 -0
  77. package/server/api/auth/password.put.ts +27 -0
  78. package/server/api/auth/status.get.ts +16 -0
  79. package/server/api/config.get.ts +10 -0
  80. package/server/api/dashboard/layout.get.ts +18 -0
  81. package/server/api/dashboard/layout.post.ts +18 -0
  82. package/server/api/db-config.get.ts +44 -0
  83. package/server/api/db-config.post.ts +73 -0
  84. package/server/api/export.get.ts +64 -0
  85. package/server/api/formats/datetime/[id].delete.ts +8 -0
  86. package/server/api/formats/datetime/[id].put.ts +15 -0
  87. package/server/api/formats/datetime.get.ts +11 -0
  88. package/server/api/formats/datetime.post.ts +16 -0
  89. package/server/api/formats/modifiers/[id].delete.ts +8 -0
  90. package/server/api/formats/modifiers/[id].put.ts +10 -0
  91. package/server/api/formats/modifiers.get.ts +10 -0
  92. package/server/api/formats/modifiers.post.ts +14 -0
  93. package/server/api/formats/number/[id].delete.ts +8 -0
  94. package/server/api/formats/number/[id].put.ts +15 -0
  95. package/server/api/formats/number.get.ts +11 -0
  96. package/server/api/formats/number.post.ts +16 -0
  97. package/server/api/formats/snippet.get.ts +87 -0
  98. package/server/api/fs/browse.get.ts +50 -0
  99. package/server/api/history/[translationId].get.ts +13 -0
  100. package/server/api/keys/[id].delete.ts +14 -0
  101. package/server/api/keys/[id].get.ts +41 -0
  102. package/server/api/keys/[id].patch.ts +20 -0
  103. package/server/api/keys/index.get.ts +98 -0
  104. package/server/api/keys/index.post.ts +17 -0
  105. package/server/api/languages/[code].delete.ts +15 -0
  106. package/server/api/languages/[id].put.ts +24 -0
  107. package/server/api/languages/index.get.ts +13 -0
  108. package/server/api/languages/index.post.ts +42 -0
  109. package/server/api/onboarding.post.ts +56 -0
  110. package/server/api/profile.get.ts +81 -0
  111. package/server/api/project-snapshot.get.ts +73 -0
  112. package/server/api/project-snapshot.post.ts +160 -0
  113. package/server/api/projects/[id].delete.ts +13 -0
  114. package/server/api/projects/[id].put.ts +40 -0
  115. package/server/api/projects/index.get.ts +19 -0
  116. package/server/api/projects/index.post.ts +34 -0
  117. package/server/api/scan.post.ts +165 -0
  118. package/server/api/settings/index.get.ts +9 -0
  119. package/server/api/settings/index.post.ts +20 -0
  120. package/server/api/setup.post.ts +39 -0
  121. package/server/api/stats/global.get.ts +126 -0
  122. package/server/api/stats.get.ts +70 -0
  123. package/server/api/sync.post.ts +179 -0
  124. package/server/api/translate.post.ts +52 -0
  125. package/server/api/translations/batch-translate.post.ts +121 -0
  126. package/server/api/translations/bulk-status.post.ts +24 -0
  127. package/server/api/translations/index.post.ts +62 -0
  128. package/server/api/translations/job/[id].get.ts +23 -0
  129. package/server/api/translations/status.post.ts +30 -0
  130. package/server/api/translations/translate-all.post.ts +18 -0
  131. package/server/api/ui-locale.get.ts +39 -0
  132. package/server/api/users/[id]/profile.get.ts +107 -0
  133. package/server/api/users/[id]/roles.put.ts +67 -0
  134. package/server/api/users/[id].delete.ts +36 -0
  135. package/server/api/users/[id].put.ts +43 -0
  136. package/server/api/users/index.get.ts +49 -0
  137. package/server/api/users/index.post.ts +89 -0
  138. package/server/consts/auto-translate.const.ts +2 -0
  139. package/server/consts/commons.const.ts +10 -0
  140. package/server/consts/db.const.ts +3 -0
  141. package/server/consts/scanner.const.ts +4 -0
  142. package/server/consts/translation-job.const.ts +8 -0
  143. package/server/db/index.ts +672 -0
  144. package/server/enums/auth.enum.ts +5 -0
  145. package/server/enums/translation.enum.ts +6 -0
  146. package/server/interfaces/profile.interface.ts +48 -0
  147. package/server/interfaces/project-config.interface.ts +9 -0
  148. package/server/interfaces/scanner.interface.ts +18 -0
  149. package/server/interfaces/translation-job.interface.ts +13 -0
  150. package/server/middleware/auth.ts +32 -0
  151. package/server/plugins/db.ts +6 -0
  152. package/server/routes/locale/[lang].get.ts +179 -0
  153. package/server/types/auth.type.ts +3 -0
  154. package/server/utils/auth.util.ts +89 -0
  155. package/server/utils/auto-translate.util.ts +112 -0
  156. package/server/utils/lang-api.util.ts +24 -0
  157. package/server/utils/mailer.util.ts +80 -0
  158. package/server/utils/project-config.util.ts +37 -0
  159. package/server/utils/scanner.uti.ts +307 -0
  160. package/server/utils/translation-job.util.ts +142 -0
  161. package/services/auth.service.ts +31 -0
  162. package/services/base.service.ts +140 -0
  163. package/services/job.service.ts +10 -0
  164. package/services/key.service.ts +26 -0
  165. package/services/language.service.ts +26 -0
  166. package/services/profile.service.ts +14 -0
  167. package/services/project.service.ts +23 -0
  168. package/services/scan.service.ts +14 -0
  169. package/services/settings.service.ts +14 -0
  170. package/services/stats.service.ts +11 -0
  171. package/services/translation.service.ts +36 -0
  172. package/services/user.service.ts +28 -0
  173. package/tsconfig.json +3 -0
  174. package/types/commons.type.ts +3 -0
  175. package/types/dashboard.type.ts +26 -0
  176. package/utils/config.util.ts +60 -0
@@ -0,0 +1,7 @@
1
+ export enum METHODS {
2
+ GET = 'GET',
3
+ POST = 'POST',
4
+ PUT = 'PUT',
5
+ DELETE = 'DELETE',
6
+ PATCH = 'PATCH',
7
+ }
@@ -0,0 +1,40 @@
1
+ // i18n-dashboard.config.js - Copy this file to your project root
2
+ export default {
3
+ // Port on which the dashboard will run
4
+ port: 3333,
5
+
6
+ // Character used to separate nested keys (e.g. 'home.title' uses '.')
7
+ keySeparator: '.',
8
+
9
+ // URL path for serving locale JSON files
10
+ // [lang] will be replaced with the language code
11
+ apiPath: '/locale/[lang].json',
12
+
13
+ // Path to your project's locale files (relative to project root)
14
+ // The dashboard will use this for syncing keys
15
+ projectRoot: './',
16
+
17
+ // Database configuration
18
+ database: {
19
+ // Options: 'better-sqlite3' (default), 'pg' (PostgreSQL), 'mysql2' (MySQL)
20
+ client: 'better-sqlite3',
21
+
22
+ // For SQLite: path to the database file
23
+ connection: './i18n-dashboard.db',
24
+
25
+ // For PostgreSQL or MySQL, use an object instead:
26
+ // connection: {
27
+ // host: 'localhost',
28
+ // port: 5432, // or 3306 for MySQL
29
+ // user: 'myuser',
30
+ // password: 'mypassword',
31
+ // database: 'i18n_dashboard',
32
+ // },
33
+ },
34
+
35
+ // Optional: Google Translate API key
36
+ // Leave empty to use the free tier (no API key required)
37
+ // googleTranslate: {
38
+ // apiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
39
+ // },
40
+ }
@@ -0,0 +1,23 @@
1
+ import type { Method } from '../types/commons.type'
2
+
3
+ export interface RequestConfig {
4
+ query?: Record<string, any>
5
+ body?: any
6
+ headers?: Record<string, string>
7
+ /** Ne pas afficher de toast en cas d'erreur (défaut: false) */
8
+ skipErrorToast?: boolean
9
+ /** Désactiver la déduplication pour cet appel (défaut: false) */
10
+ skipDedup?: boolean
11
+ }
12
+
13
+ export interface RequestContext {
14
+ method: Method
15
+ path: string
16
+ config: RequestConfig
17
+ }
18
+
19
+ export interface ServiceHooks {
20
+ beforeRequest?: (ctx: RequestContext) => Promise<void> | void
21
+ afterRequest?: (ctx: RequestContext, response: any) => Promise<void> | void
22
+ onError?: (ctx: RequestContext, error: any) => Promise<void> | void
23
+ }
@@ -0,0 +1,10 @@
1
+ export interface JobStatus {
2
+ id: string
3
+ status: 'running' | 'done' | 'error'
4
+ languageCode: string
5
+ languageName: string
6
+ total: number
7
+ done: number
8
+ errors: number
9
+ percent: number
10
+ }
@@ -0,0 +1,39 @@
1
+ export interface KeysQuery {
2
+ project_id?: number
3
+ search?: string
4
+ lang?: string
5
+ status?: string
6
+ page?: number
7
+ limit?: number
8
+ }
9
+
10
+ export interface KeyItem {
11
+ id: number
12
+ key: string
13
+ description?: string
14
+ is_unused: boolean
15
+ translations: Record<string, TranslationItem>
16
+ usages: UsageItem[]
17
+ }
18
+
19
+ export interface TranslationItem {
20
+ id: number
21
+ value: string
22
+ status: string
23
+ language_code: string
24
+ key_id: number
25
+ }
26
+
27
+ export interface UsageItem {
28
+ file_path: string
29
+ line_number: number
30
+ detected_function: string
31
+ }
32
+
33
+ export interface KeysResponse {
34
+ data: KeyItem[]
35
+ total: number
36
+ page: number
37
+ limit: number
38
+ languages: any[]
39
+ }
@@ -0,0 +1,23 @@
1
+ export interface Language {
2
+ code: string
3
+ name: string
4
+ nativeName: string
5
+ }
6
+
7
+ export interface LanguageItem {
8
+ id: number
9
+ code: string
10
+ name: string
11
+ is_default: boolean
12
+ fallback_code: string | null
13
+ project_id: number
14
+ project_name?: string
15
+ project_color?: string
16
+ }
17
+
18
+ export interface CreateLanguagePayload {
19
+ project_id: number
20
+ code: string
21
+ name: string
22
+ is_default?: boolean
23
+ }
@@ -0,0 +1,9 @@
1
+ export interface ProjectPayload {
2
+ name: string
3
+ root_path?: string
4
+ source_url?: string
5
+ locales_path: string
6
+ key_separator: string
7
+ color?: string
8
+ description?: string
9
+ }
@@ -0,0 +1,12 @@
1
+ export interface ScanResult {
2
+ keysFound: number
3
+ keysAdded: number
4
+ langsAdded: number
5
+ scannedFiles: number
6
+ }
7
+
8
+ export interface SyncResult {
9
+ added: number
10
+ updated: number
11
+ total: number
12
+ }
@@ -0,0 +1,4 @@
1
+ export interface SettingsPayload {
2
+ scan_exclude?: string
3
+ google_translate_api_key?: string
4
+ }
@@ -0,0 +1,30 @@
1
+ export interface LangStat {
2
+ id: number
3
+ code: string
4
+ name: string
5
+ is_default: boolean
6
+ total: number
7
+ translated: number
8
+ missing: number
9
+ draft: number
10
+ reviewed: number
11
+ approved: number
12
+ coverage: number
13
+ }
14
+
15
+ export interface StatsResponse {
16
+ totalKeys: number
17
+ unusedKeys: number
18
+ languages: LangStat[]
19
+ recentActivity: ActivityItem[]
20
+ }
21
+
22
+ export interface ActivityItem {
23
+ id: number
24
+ old_value: string | null
25
+ new_value: string
26
+ changed_by: string
27
+ changed_at: string
28
+ key: string
29
+ language_code: string
30
+ }
@@ -0,0 +1,11 @@
1
+ export interface SaveTranslationPayload {
2
+ key_id: number
3
+ language_code: string
4
+ value: string
5
+ }
6
+
7
+ export interface SetStatusPayload {
8
+ key_id: number
9
+ language_code: string
10
+ status: string
11
+ }
@@ -0,0 +1,24 @@
1
+ export interface UserItem {
2
+ id: number
3
+ name: string
4
+ email: string
5
+ is_active: boolean
6
+ is_super_admin?: boolean
7
+ last_login_at?: string | null
8
+ role?: string
9
+ roles?: Array<{ role: string; project_id: number | null; project_name?: string }>
10
+ }
11
+
12
+ export interface CreateUserPayload {
13
+ name: string
14
+ email: string
15
+ role: string
16
+ project_id?: number
17
+ project_ids?: number[]
18
+ global_access?: boolean
19
+ }
20
+
21
+ export interface RoleEntry {
22
+ project_id: number | null
23
+ role: string | null
24
+ }
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <div class="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-950 dark:to-gray-900 flex items-center justify-center p-4">
3
+ <slot />
4
+ </div>
5
+ </template>
@@ -0,0 +1,327 @@
1
+ <template>
2
+ <div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex">
3
+
4
+ <!-- ── Sidebar ─────────────────────────────────────────────────────────── -->
5
+ <aside class="w-56 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
6
+
7
+ <!-- Logo -->
8
+ <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
9
+ <NuxtLink to="/" class="flex items-center gap-2.5">
10
+ <div class="w-8 h-8 rounded-lg bg-primary-500 flex items-center justify-center shrink-0">
11
+ <UIcon name="i-heroicons-language" class="text-white text-base" />
12
+ </div>
13
+ <div>
14
+ <h1 class="text-sm font-bold text-gray-900 dark:text-white leading-tight">i18n Dashboard</h1>
15
+ <p class="text-xs text-gray-400">vue-i18n manager</p>
16
+ </div>
17
+ </NuxtLink>
18
+ </div>
19
+
20
+ <!-- ── Project context sidebar ──────────────────────────────────────── -->
21
+ <template v-if="inProjectContext">
22
+ <!-- Back + Project name -->
23
+ <div class="p-2 border-b border-gray-200 dark:border-gray-800">
24
+ <NuxtLink
25
+ to="/projects"
26
+ class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-gray-400 hover:text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors mb-1"
27
+ >
28
+ <UIcon name="i-heroicons-arrow-left" class="text-sm" />
29
+ {{ t('nav.all_projects', 'All projects') }}
30
+ </NuxtLink>
31
+ <div v-if="currentProject" class="flex items-center gap-2 px-2 py-1.5">
32
+ <span class="w-2.5 h-2.5 rounded-full shrink-0" :class="`bg-${currentProject.color || 'primary'}-500`" />
33
+ <span class="text-sm font-semibold text-gray-900 dark:text-white truncate">{{ currentProject.name }}</span>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Scan / Sync actions -->
38
+ <div v-if="currentProject && userCanManage && !currentProject.is_system" class="p-2 border-b border-gray-200 dark:border-gray-800 space-y-1">
39
+ <UButton block variant="soft" color="neutral" size="sm" icon="i-heroicons-magnifying-glass"
40
+ @click="showScanModal = true">
41
+ {{ t('sidebar.scan', 'Scan project') }}
42
+ </UButton>
43
+ <UTooltip :text="t('sidebar.sync_disabled_hint', 'Requiert un chemin local ou une URL distante')" :disabled="canSyncProject(currentProject)">
44
+ <UButton block variant="soft" color="neutral" size="sm" icon="i-heroicons-arrow-path"
45
+ :loading="syncing !== null" :disabled="!canSyncProject(currentProject)" @click="doSync">
46
+ {{ t('sidebar.sync', 'Sync JSON') }}
47
+ </UButton>
48
+ </UTooltip>
49
+ </div>
50
+
51
+ <!-- Project navigation -->
52
+ <nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
53
+ <NuxtLink
54
+ v-for="item in projectNavigation"
55
+ :key="item.to"
56
+ :to="item.to"
57
+ class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
58
+ :class="isActive(item.to)
59
+ ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 font-medium'
60
+ : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'"
61
+ >
62
+ <UIcon :name="item.icon" class="text-base shrink-0" />
63
+ <span class="flex-1">{{ item.label }}</span>
64
+ <UBadge v-if="item.badge" size="xs" :color="item.badgeColor || 'neutral'">{{ item.badge }}</UBadge>
65
+ </NuxtLink>
66
+ </nav>
67
+ </template>
68
+
69
+ <!-- ── Global sidebar ───────────────────────────────────────────────── -->
70
+ <template v-else>
71
+ <div class="p-2 border-b border-gray-200 dark:border-gray-800">
72
+ <p class="text-xs text-gray-400 font-medium px-2 mb-1.5 uppercase tracking-wide">{{ t('sidebar.project_label', 'Projects') }}</p>
73
+
74
+ <div v-if="!userProjects.length" class="px-2 py-2">
75
+ <p class="text-xs text-gray-400 italic mb-2">{{ t('sidebar.no_project', 'No project configured') }}</p>
76
+ <UButton size="xs" block icon="i-heroicons-plus" to="/projects">
77
+ {{ t('sidebar.add_project', 'Add a project') }}
78
+ </UButton>
79
+ </div>
80
+
81
+ <div v-else class="space-y-0.5">
82
+ <NuxtLink
83
+ v-for="project in userProjects"
84
+ :key="project.id"
85
+ :to="`/projects/${project.id}`"
86
+ class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors text-left"
87
+ :class="'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'"
88
+ >
89
+ <span class="w-2.5 h-2.5 rounded-full shrink-0" :class="`bg-${project.color || 'primary'}-500`" />
90
+ <span class="flex-1 truncate font-medium">{{ project.name }}</span>
91
+ <UIcon v-if="project.is_system" name="i-heroicons-lock-closed" class="text-xs text-gray-400 shrink-0" />
92
+ </NuxtLink>
93
+
94
+ <NuxtLink
95
+ to="/projects"
96
+ class="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-gray-400 hover:text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
97
+ >
98
+ <UIcon name="i-heroicons-rectangle-stack" class="text-sm" />
99
+ {{ t('sidebar.manage_projects', 'Manage projects') }}
100
+ </NuxtLink>
101
+ </div>
102
+ </div>
103
+
104
+ <div class="flex-1" />
105
+ </template>
106
+
107
+ <!-- Admin section (super admin only) -->
108
+ <div v-if="isSuperAdmin" class="p-2 border-t border-gray-200 dark:border-gray-800">
109
+ <p class="text-xs text-gray-400 font-medium px-2 mb-1.5 uppercase tracking-wide">{{ t('nav.administration', 'Administration') }}</p>
110
+ <NuxtLink
111
+ to="/users"
112
+ class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
113
+ :class="isActive('/users')
114
+ ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 font-medium'
115
+ : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'"
116
+ >
117
+ <UIcon name="i-heroicons-user-group" class="text-base shrink-0" />
118
+ <span class="flex-1">{{ t('users.all_title', 'All users') }}</span>
119
+ </NuxtLink>
120
+ </div>
121
+ </aside>
122
+
123
+ <!-- ── Right column ────────────────────────────────────────────────────── -->
124
+ <div class="flex-1 flex flex-col min-w-0">
125
+
126
+ <!-- Header -->
127
+ <header class="h-12 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 flex items-center justify-end gap-2 px-4 shrink-0">
128
+
129
+ <!-- UI Language -->
130
+ <USelect
131
+ v-if="uiLangsOptions.length > 1"
132
+ :model-value="uiLang"
133
+ :items="uiLangsOptions"
134
+ size="xs"
135
+ class="w-36"
136
+ @update:model-value="setLang"
137
+ />
138
+
139
+ <!-- Theme toggle -->
140
+ <UButton
141
+ :icon="isDark ? 'i-heroicons-sun' : 'i-heroicons-moon'"
142
+ color="neutral"
143
+ variant="ghost"
144
+ size="sm"
145
+ @click="toggleDark"
146
+ />
147
+
148
+ <UDivider orientation="vertical" class="h-5" />
149
+
150
+ <!-- User menu -->
151
+ <UDropdownMenu :items="userMenuItems">
152
+ <button class="flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
153
+ <div class="w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
154
+ <span class="text-xs font-bold text-primary-600 dark:text-primary-400">
155
+ {{ currentUser?.name?.charAt(0)?.toUpperCase() || '?' }}
156
+ </span>
157
+ </div>
158
+ <span class="text-sm font-medium text-gray-700 dark:text-gray-300 max-w-32 truncate">{{ currentUser?.name }}</span>
159
+ <UIcon name="i-heroicons-chevron-down" class="text-xs text-gray-400" />
160
+ </button>
161
+ </UDropdownMenu>
162
+ </header>
163
+
164
+ <!-- Page content -->
165
+ <main class="flex-1 overflow-auto min-w-0">
166
+ <slot />
167
+ </main>
168
+ </div>
169
+ </div>
170
+
171
+ <!-- Scan modal -->
172
+ <ScanModal
173
+ v-if="currentProject"
174
+ v-model:open="showScanModal"
175
+ :project-id="currentProject.id"
176
+ :project="currentProject"
177
+ @done="fetchProjects"
178
+ />
179
+
180
+ <!-- Change password modal -->
181
+ <UModal v-model:open="showPasswordModal" :title="t('user.change_password_title', 'Change password')">
182
+ <template #body>
183
+ <div class="space-y-4">
184
+ <UFormField :label="t('user.current_password', 'Current password')" required>
185
+ <UInput v-model="passwordForm.current" type="password" class="w-full" />
186
+ </UFormField>
187
+ <UFormField :label="t('user.new_password', 'New password')" :hint="t('user.password_hint', 'Minimum 8 characters')" required>
188
+ <UInput v-model="passwordForm.next" type="password" class="w-full" />
189
+ </UFormField>
190
+ <UFormField :label="t('user.confirm_password', 'Confirm')" required>
191
+ <UInput v-model="passwordForm.confirm" type="password" class="w-full" />
192
+ </UFormField>
193
+ <p v-if="passwordError" class="text-sm text-red-500">{{ passwordError }}</p>
194
+ </div>
195
+ </template>
196
+ <template #footer>
197
+ <div class="flex justify-end gap-3">
198
+ <UButton color="neutral" variant="ghost" @click="showPasswordModal = false">{{ t('translations.cancel', 'Cancel') }}</UButton>
199
+ <UButton :loading="passwordSaving" @click="changePassword">{{ t('user.save_password', 'Save') }}</UButton>
200
+ </div>
201
+ </template>
202
+ </UModal>
203
+ </template>
204
+
205
+ <script setup lang="ts">
206
+ import { canSyncProject } from '~/composables/useProject'
207
+
208
+ const route = useRoute()
209
+ const router = useRouter()
210
+ const toast = useToast()
211
+ const colorMode = useColorMode()
212
+ const { currentProject, projects: projectsData, systemProject, fetchProjects, visibleProjects: userProjects, syncing, syncProject } = useProject()
213
+ const showScanModal = ref(false)
214
+ const { currentUser, fetchMe, logout, changePassword: changePasswordFn, canManageProject, canApprove } = useAuth()
215
+ const { t, lang: uiLang, setLang, getLangs } = useT()
216
+ const { findLanguage } = useLanguages()
217
+
218
+ const inProjectContext = computed(() => route.path.includes('projects') && !!route.params.id)
219
+
220
+ const uiLangs = ref<any[]>([])
221
+ watch(
222
+ () => systemProject.value?.id,
223
+ async (projectId) => {
224
+ uiLangs.value = projectId ? await getLangs(projectId) as any[] : []
225
+ },
226
+ { immediate: true, flush: 'post' },
227
+ )
228
+
229
+ const uiLangsOptions = computed(() =>
230
+ uiLangs.value.map((l: any) => {
231
+ const meta = findLanguage(l.code)
232
+ const label = meta ? meta.nativeName : l.name
233
+ return { label, value: l.code }
234
+ }),
235
+ )
236
+
237
+ const isDark = computed(() => colorMode.value === 'dark')
238
+ function toggleDark() {
239
+ colorMode.preference = isDark.value ? 'light' : 'dark'
240
+ }
241
+
242
+ const userCanManage = computed(() => currentProject.value ? canManageProject(currentProject.value.id) : false)
243
+ const userCanApprove = computed(() => currentProject.value ? canApprove(currentProject.value.id) : false)
244
+ const isSuperAdmin = computed(() => currentUser.value?.is_super_admin ?? false)
245
+
246
+ onMounted(() => {
247
+ fetchMe()
248
+ fetchProjects()
249
+ })
250
+
251
+
252
+ function isActive(to: string) {
253
+ const [toPath, toQuery] = to.split('?')
254
+ const projectBase = `/projects/${route.params.id}`
255
+ if (toPath === projectBase) return route.path === projectBase
256
+ if (to === '/') return route.path === '/'
257
+ if (!route.path.startsWith(toPath)) return false
258
+ if (toQuery) {
259
+ const params = new URLSearchParams(toQuery)
260
+ return [...params.entries()].every(([k, v]) => route.query[k] === v)
261
+ }
262
+ return true
263
+ }
264
+
265
+ const { stats: statsData } = useStats()
266
+
267
+ const projectNavigation = computed(() => {
268
+ const id = route.params.id
269
+ if (!id) return []
270
+ const s = statsData.value as any
271
+ const translateCount = s?.totalKeys || 0
272
+ const unusedCount = s?.unusedKeys || 0
273
+ const reviewedCount = (s?.languages || []).reduce((sum: number, l: any) => sum + (l.draft || 0), 0)
274
+ const languageCount = (s?.languages || []).length
275
+
276
+ return [
277
+ { to: `/projects/${id}`, label: t('nav.dashboard', 'Dashboard'), icon: 'i-heroicons-chart-bar-square' },
278
+ { to: `/projects/${id}/translations`, label: t('nav.translations', 'Translations'), icon: 'i-heroicons-globe-alt', badge: translateCount || undefined, badgeColor: 'info' as const },
279
+ { to: `/projects/${id}/translations?status=unused`, label: t('nav.unused', 'Unused'), icon: 'i-heroicons-exclamation-triangle', badge: unusedCount || undefined, badgeColor: 'warning' as const },
280
+ { to: `/projects/${id}/languages`, label: t('nav.languages', 'Languages'), icon: 'i-heroicons-flag', badge: languageCount || undefined, badgeColor: 'info' as const },
281
+ ...(userCanApprove.value ? [{ to: `/projects/${id}/review`, label: t('nav.review', 'Review'), icon: 'i-heroicons-clipboard-document-check', badge: reviewedCount || undefined, badgeColor: 'warning' as const }] : []),
282
+ { to: `/projects/${id}/settings`, label: t('nav.settings', 'Settings'), icon: 'i-heroicons-cog-6-tooth' },
283
+ ...(userCanManage.value || currentUser.value?.is_super_admin ? [{ to: `/projects/${id}/users`, label: t('nav.users', 'Users'), icon: 'i-heroicons-users' }] : []),
284
+ ...(currentProject.value?.enable_number_formats ? [{ to: `/projects/${id}/formats/number`, label: t('nav.format_numbers', 'Number formats'), icon: 'i-heroicons-calculator' }] : []),
285
+ ...(currentProject.value?.enable_datetime_formats ? [{ to: `/projects/${id}/formats/datetime`, label: t('nav.format_dates', 'Date formats'), icon: 'i-heroicons-calendar' }] : []),
286
+ ...(currentProject.value?.enable_modifiers ? [{ to: `/projects/${id}/formats/modifiers`, label: t('nav.modifiers', 'Modifiers'), icon: 'i-heroicons-code-bracket' }] : []),
287
+ ]
288
+ })
289
+
290
+ const userMenuItems = computed(() => [
291
+ [{ label: t('user.profile', 'My profile'), icon: 'i-heroicons-user-circle', onSelect: () => router.push(`/users/${currentUser.value?.id}/profile`) }],
292
+ [{ label: t('user.change_password', 'Change password'), icon: 'i-heroicons-key', onSelect: () => showPasswordModal.value = true }],
293
+ [{ label: t('user.logout', 'Log out'), icon: 'i-heroicons-arrow-right-on-rectangle', color: 'error' as const, onSelect: handleLogout }],
294
+ ])
295
+
296
+ const showPasswordModal = ref(false)
297
+ const passwordForm = ref({ current: '', next: '', confirm: '' })
298
+ const passwordError = ref('')
299
+ const passwordSaving = ref(false)
300
+
301
+ async function handleLogout() {
302
+ await logout()
303
+ router.push('/login')
304
+ }
305
+
306
+ async function changePassword() {
307
+ passwordError.value = ''
308
+ if (passwordForm.value.next.length < 8) { passwordError.value = t('user.password_hint', 'Minimum 8 characters'); return }
309
+ if (passwordForm.value.next !== passwordForm.value.confirm) { passwordError.value = t('user.passwords_mismatch', 'Passwords do not match'); return }
310
+ passwordSaving.value = true
311
+ try {
312
+ await changePasswordFn(passwordForm.value.current, passwordForm.value.next)
313
+ toast.add({ title: t('user.password_changed', 'Password changed'), color: 'success' })
314
+ showPasswordModal.value = false
315
+ passwordForm.value = { current: '', next: '', confirm: '' }
316
+ } catch (e: any) {
317
+ passwordError.value = e.message || 'Error'
318
+ } finally {
319
+ passwordSaving.value = false
320
+ }
321
+ }
322
+
323
+ async function doSync() {
324
+ if (!currentProject.value) return
325
+ await syncProject(currentProject.value)
326
+ }
327
+ </script>
@@ -0,0 +1,26 @@
1
+ export default defineNuxtRouteMiddleware(async (to) => {
2
+ if (to.path === '/login') return
3
+
4
+ const { data: status } = await useFetch('/api/auth/status', { key: 'auth-status' })
5
+
6
+ if (!status.value) return
7
+
8
+ // Aucun utilisateur en base → onboarding (création du compte)
9
+ if (!status.value.hasUsers) {
10
+ if (to.path !== '/onboarding') return navigateTo('/onboarding')
11
+ return
12
+ }
13
+
14
+ // Non connecté → login
15
+ if (!status.value.isLoggedIn) return navigateTo('/login')
16
+
17
+ // Onboarding non terminé → redirect (sauf si déjà sur /onboarding)
18
+ if (!status.value.onboardingCompleted && to.path !== '/onboarding') {
19
+ return navigateTo('/onboarding')
20
+ }
21
+
22
+ // Onboarding terminé → ne pas rester sur /onboarding
23
+ if (status.value.onboardingCompleted && to.path === '/onboarding') {
24
+ return navigateTo('/')
25
+ }
26
+ })
package/nuxt.config.ts ADDED
@@ -0,0 +1,66 @@
1
+ // https://nuxt.com/docs/api/configuration/nuxt-config
2
+ export default defineNuxtConfig({
3
+ compatibilityDate: '2025-01-01',
4
+ devtools: { enabled: false },
5
+
6
+ modules: ['@nuxt/ui'],
7
+
8
+ css: ['~/assets/css/main.css'],
9
+
10
+ ui: {
11
+ colorMode: true,
12
+ },
13
+
14
+ runtimeConfig: {
15
+ // Database configuration (overridable via env vars or i18n-dashboard.config)
16
+ dbClient: process.env.I18N_DB_CLIENT || 'better-sqlite3',
17
+ dbConnection: process.env.I18N_DB_CONNECTION || './i18n-dashboard.db',
18
+ dbHost: process.env.I18N_DB_HOST || 'localhost',
19
+ dbPort: process.env.I18N_DB_PORT || '5432',
20
+ dbUser: process.env.I18N_DB_USER || '',
21
+ dbPassword: process.env.I18N_DB_PASSWORD || '',
22
+ dbName: process.env.I18N_DB_NAME || 'i18n_dashboard',
23
+ googleTranslateApiKey: process.env.GOOGLE_TRANSLATE_API_KEY || '',
24
+ // Only set when the CLI passes I18N_PROJECT_ROOT explicitly (not defaulted)
25
+ projectRoot: process.env.I18N_PROJECT_ROOT || '',
26
+ localesPath: process.env.I18N_LOCALES_PATH || 'src/locales',
27
+ // Auth
28
+ sessionSecret: process.env.SESSION_SECRET || 'i18n-dashboard-default-secret-change-me-in-production!!',
29
+ // Email (SMTP) — optional
30
+ smtpHost: process.env.SMTP_HOST || '',
31
+ smtpPort: process.env.SMTP_PORT || '587',
32
+ smtpSecure: process.env.SMTP_SECURE || 'false',
33
+ smtpUser: process.env.SMTP_USER || '',
34
+ smtpPass: process.env.SMTP_PASS || '',
35
+ smtpFrom: process.env.SMTP_FROM || 'noreply@i18n-dashboard.local',
36
+ dashboardUrl: process.env.DASHBOARD_URL || 'http://localhost:3333',
37
+
38
+ // Public runtime config
39
+ public: {
40
+ apiPath: process.env.I18N_API_PATH || '/locale/[lang].json',
41
+ keySeparator: process.env.I18N_KEY_SEPARATOR || '.',
42
+ localesPath: process.env.I18N_LOCALES_PATH || 'src/locales',
43
+ dashboardPort: process.env.I18N_PORT || '3333',
44
+ },
45
+ },
46
+
47
+ nitro: {
48
+ watchOptions: {
49
+ ignored: ['**/i18n-dashboard.db.json'],
50
+ },
51
+ serverAssets: [
52
+ {
53
+ baseName: 'locales',
54
+ dir: './assets/locales',
55
+ },
56
+ ],
57
+ },
58
+
59
+ typescript: {
60
+ strict: false,
61
+ },
62
+
63
+ devServer: {
64
+ port: parseInt(process.env.I18N_PORT || '3333'),
65
+ },
66
+ })