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,672 @@
1
+ import knex, { type Knex } from 'knex'
2
+ import { resolve, basename } from 'path'
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
4
+ import { useRuntimeConfig } from '#imports'
5
+
6
+ import { OVERRIDE_FILE } from '../consts/db.const'
7
+ import { _dirname } from '../../consts/commons.const'
8
+
9
+ let _db: Knex | null = null
10
+
11
+ function readOverride(): Record<string, string> | null {
12
+ if (existsSync(OVERRIDE_FILE)) {
13
+ try { return JSON.parse(readFileSync(OVERRIDE_FILE, 'utf-8')) } catch { /* ignore */ }
14
+ }
15
+ return null
16
+ }
17
+
18
+ function buildConnectionFromParams(params: {
19
+ dbClient: string
20
+ dbConnection?: string
21
+ dbHost?: string
22
+ dbPort?: string
23
+ dbUser?: string
24
+ dbPassword?: string
25
+ dbName?: string
26
+ }): Knex.Config {
27
+ const client = params.dbClient || 'better-sqlite3'
28
+
29
+ if (client === 'better-sqlite3' || client === 'sqlite3') {
30
+ const dbPath = params.dbConnection || './i18n-dashboard.db'
31
+ const resolvedPath = dbPath.startsWith('.') ? resolve(process.cwd(), dbPath) : dbPath
32
+ return {
33
+ client: 'better-sqlite3',
34
+ connection: { filename: resolvedPath },
35
+ useNullAsDefault: true,
36
+ }
37
+ }
38
+
39
+ if (client === 'pg' || client === 'postgresql') {
40
+ return {
41
+ client: 'pg',
42
+ connection: {
43
+ host: params.dbHost,
44
+ port: parseInt(params.dbPort || '5432'),
45
+ user: params.dbUser,
46
+ password: params.dbPassword,
47
+ database: params.dbName,
48
+ },
49
+ }
50
+ }
51
+
52
+ if (client === 'mysql2' || client === 'mysql') {
53
+ return {
54
+ client: 'mysql2',
55
+ connection: {
56
+ host: params.dbHost,
57
+ port: parseInt(params.dbPort || '3306'),
58
+ user: params.dbUser,
59
+ password: params.dbPassword,
60
+ database: params.dbName,
61
+ },
62
+ }
63
+ }
64
+
65
+ throw new Error(`Unsupported database client: ${client}`)
66
+ }
67
+
68
+ function buildConnection(config: ReturnType<typeof useRuntimeConfig>): Knex.Config {
69
+ const override = readOverride()
70
+ const params = {
71
+ dbClient: (override?.dbClient || config.dbClient || 'better-sqlite3') as string,
72
+ dbConnection: (override?.dbConnection || config.dbConnection) as string | undefined,
73
+ dbHost: (override?.dbHost || config.dbHost) as string | undefined,
74
+ dbPort: (override?.dbPort || config.dbPort) as string | undefined,
75
+ dbUser: (override?.dbUser || config.dbUser) as string | undefined,
76
+ dbPassword: (override?.dbPassword || config.dbPassword) as string | undefined,
77
+ dbName: (override?.dbName || config.dbName) as string | undefined,
78
+ }
79
+ return buildConnectionFromParams(params)
80
+ }
81
+
82
+ export function getDb(): Knex {
83
+ if (_db) return _db
84
+ const config = useRuntimeConfig()
85
+ _db = knex(buildConnection(config))
86
+ return _db
87
+ }
88
+
89
+ export async function resetDb(knexConfig: Knex.Config): Promise<void> {
90
+ if (_db) {
91
+ await _db.destroy()
92
+ _db = null
93
+ }
94
+ _db = knex(knexConfig)
95
+ await initDb()
96
+ }
97
+
98
+ export function saveDbOverride(override: Record<string, string>): void {
99
+ writeFileSync(OVERRIDE_FILE, JSON.stringify(override, null, 2))
100
+ }
101
+
102
+ export { buildConnectionFromParams }
103
+
104
+ // Helper: safely add a column only if it doesn't exist yet
105
+ async function addColumnIfMissing(db: Knex, tableName: string, columnName: string, addFn: (table: any) => void) {
106
+ try {
107
+ const exists = await db.schema.hasColumn(tableName, columnName)
108
+ if (!exists) {
109
+ await db.schema.table(tableName, addFn)
110
+ }
111
+ } catch (e: any) {
112
+ if (!e.message?.includes('duplicate column') && !e.message?.includes('already exists')) {
113
+ throw e
114
+ }
115
+ }
116
+ }
117
+
118
+ // Reads a dashboard-ui locale JSON file bundled with the package
119
+ function readLocaleFile(lang: string): Record<string, string> {
120
+ const candidates = [
121
+ resolve(process.cwd(), `assets/locales/${lang}.json`),
122
+ resolve(_dirname, `../../assets/locales/${lang}.json`),
123
+ resolve(_dirname, `../assets/locales/${lang}.json`),
124
+ ]
125
+ for (const p of candidates) {
126
+ if (existsSync(p)) {
127
+ try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { /* ignore */ }
128
+ }
129
+ }
130
+ return {}
131
+ }
132
+
133
+ // Dashboard UI strings seeded into the system project (FR default + EN)
134
+ const DASHBOARD_UI_STRINGS: Array<{ key: string; fr: string; en: string }> = [
135
+ // Navigation
136
+ { key: 'nav.dashboard', fr: 'Dashboard', en: 'Dashboard' },
137
+ { key: 'nav.translations', fr: 'Traductions', en: 'Translations' },
138
+ { key: 'nav.unused', fr: 'Inutilisées', en: 'Unused' },
139
+ { key: 'nav.languages', fr: 'Langues', en: 'Languages' },
140
+ { key: 'nav.review', fr: 'À réviser', en: 'Review' },
141
+ { key: 'nav.settings', fr: 'Paramètres', en: 'Settings' },
142
+ { key: 'nav.users', fr: 'Utilisateurs', en: 'Users' },
143
+ // Sidebar
144
+ { key: 'sidebar.project_label', fr: 'PROJET', en: 'PROJECT' },
145
+ { key: 'sidebar.no_project', fr: 'Aucun projet configuré', en: 'No project configured' },
146
+ { key: 'sidebar.add_project', fr: 'Ajouter un projet', en: 'Add a project' },
147
+ { key: 'sidebar.manage_projects', fr: 'Gérer les projets', en: 'Manage projects' },
148
+ { key: 'sidebar.scan', fr: 'Scanner le projet', en: 'Scan project' },
149
+ { key: 'sidebar.sync', fr: 'Sync JSON', en: 'Sync JSON' },
150
+ { key: 'sidebar.no_project_selected', fr: 'Sélectionnez un projet pour commencer', en: 'Select a project to get started' },
151
+ { key: 'sidebar.theme', fr: 'Thème', en: 'Theme' },
152
+ { key: 'sidebar.ui_lang', fr: 'Langue interface', en: 'UI Language' },
153
+ // Project empty state
154
+ { key: 'project.none_selected', fr: 'Aucun projet sélectionné', en: 'No project selected' },
155
+ { key: 'project.none_selected_hint', fr: 'Ajoutez votre projet Vue.js pour commencer à gérer vos traductions.', en: 'Add your Vue.js project to start managing your translations.' },
156
+ { key: 'project.add_button', fr: 'Ajouter un projet', en: 'Add a project' },
157
+ // User menu
158
+ { key: 'user.super_admin', fr: 'Super Admin', en: 'Super Admin' },
159
+ { key: 'user.role_user', fr: 'Utilisateur', en: 'User' },
160
+ { key: 'user.profile', fr: 'Mon profil', en: 'My profile' },
161
+ { key: 'user.change_password', fr: 'Changer le mot de passe', en: 'Change password' },
162
+ { key: 'user.logout', fr: 'Se déconnecter', en: 'Log out' },
163
+ { key: 'user.change_password_title', fr: 'Changer le mot de passe', en: 'Change password' },
164
+ { key: 'user.current_password', fr: 'Mot de passe actuel', en: 'Current password' },
165
+ { key: 'user.new_password', fr: 'Nouveau mot de passe', en: 'New password' },
166
+ { key: 'user.confirm_password', fr: 'Confirmer', en: 'Confirm' },
167
+ { key: 'user.password_hint', fr: 'Minimum 8 caractères', en: 'Minimum 8 characters' },
168
+ { key: 'user.save_password', fr: 'Modifier', en: 'Save' },
169
+ { key: 'user.password_changed', fr: 'Mot de passe modifié', en: 'Password changed' },
170
+ // Translations page
171
+ { key: 'translations.title', fr: 'Traductions', en: 'Translations' },
172
+ { key: 'translations.keys_count', fr: 'clés', en: 'keys' },
173
+ { key: 'translations.langs_count', fr: 'langues', en: 'languages' },
174
+ { key: 'translations.search', fr: 'Rechercher une clé...', en: 'Search for a key...' },
175
+ { key: 'translations.add_key', fr: 'Nouvelle clé', en: 'New key' },
176
+ { key: 'translations.translate_all', fr: 'Traduire tout', en: 'Translate all' },
177
+ { key: 'translations.no_results', fr: 'Aucune clé trouvée', en: 'No keys found' },
178
+ { key: 'translations.no_results_hint', fr: 'Essayez un autre terme de recherche.', en: 'Try a different search term.' },
179
+ { key: 'translations.add_description', fr: 'Ajouter une description…', en: 'Add a description…' },
180
+ { key: 'translations.click_to_add', fr: 'Cliquer pour ajouter...', en: 'Click to add...' },
181
+ { key: 'translations.save', fr: 'Sauvegarder', en: 'Save' },
182
+ { key: 'translations.cancel', fr: 'Annuler', en: 'Cancel' },
183
+ { key: 'translations.add_key_title', fr: 'Nouvelle clé de traduction', en: 'New translation key' },
184
+ { key: 'translations.key_label', fr: 'Clé', en: 'Key' },
185
+ { key: 'translations.key_hint', fr: 'Exemple: home.title ou nav.menu.about', en: 'Example: home.title or nav.menu.about' },
186
+ { key: 'translations.description_label', fr: 'Description', en: 'Description' },
187
+ { key: 'translations.description_hint', fr: 'Contexte pour les traducteurs', en: 'Context for translators' },
188
+ { key: 'translations.create', fr: 'Créer', en: 'Create' },
189
+ { key: 'translations.delete_key', fr: 'Supprimer la clé', en: 'Delete key' },
190
+ { key: 'translations.key_deleted', fr: 'Clé supprimée', en: 'Key deleted' },
191
+ { key: 'translations.history', fr: 'Historique par langue', en: 'History by language' },
192
+ { key: 'translations.references', fr: 'référence', en: 'reference' },
193
+ { key: 'translations.references_plural', fr: 'références', en: 'references' },
194
+ // Status
195
+ { key: 'status.all', fr: 'Tout', en: 'All' },
196
+ { key: 'status.draft', fr: 'Brouillon', en: 'Draft' },
197
+ { key: 'status.reviewed', fr: 'Relu', en: 'Reviewed' },
198
+ { key: 'status.approved', fr: 'Approuvé', en: 'Approved' },
199
+ { key: 'status.missing', fr: 'Manquant', en: 'Missing' },
200
+ { key: 'status.unused', fr: 'Inutilisé', en: 'Unused' },
201
+ { key: 'status.rejected', fr: 'Refusé', en: 'Rejected' },
202
+ // Key detail page
203
+ { key: 'key.rejected_notice', fr: 'Cette traduction a été refusée. Merci de la mettre à jour.', en: 'This translation was rejected. Please update it.' },
204
+ { key: 'key.restore', fr: 'Restaurer', en: 'Restore' },
205
+ // Review page
206
+ { key: 'review.title', fr: 'File de révision', en: 'Review queue' },
207
+ { key: 'review.approve_all', fr: 'Tout approuver', en: 'Approve all' },
208
+ { key: 'review.empty_title', fr: 'Aucune traduction en attente', en: 'No translations pending' },
209
+ { key: 'review.empty_hint', fr: 'Toutes les traductions relues ont déjà été approuvées.', en: 'All reviewed translations have already been approved.' },
210
+ { key: 'review.approve', fr: 'Approuver', en: 'Approve' },
211
+ { key: 'review.back_to_draft', fr: 'Repasser en brouillon', en: 'Back to draft' },
212
+ { key: 'review.approved_toast', fr: 'Approuvées', en: 'Approved' },
213
+ // Languages page
214
+ { key: 'languages.title', fr: 'Langues', en: 'Languages' },
215
+ { key: 'languages.subtitle', fr: 'Gérez les langues du projet', en: 'Manage project languages' },
216
+ { key: 'languages.add', fr: 'Ajouter une langue', en: 'Add a language' },
217
+ { key: 'languages.none', fr: 'Aucune langue configurée', en: 'No language configured' },
218
+ { key: 'languages.none_hint', fr: 'Ajoutez des langues pour commencer à traduire.', en: 'Add languages to start translating.' },
219
+ { key: 'languages.default_badge', fr: 'Défaut', en: 'Default' },
220
+ // Settings
221
+ { key: 'settings.title', fr: 'Paramètres', en: 'Settings' },
222
+ { key: 'settings.save', fr: 'Sauvegarder', en: 'Save' },
223
+ // Users
224
+ { key: 'users.title', fr: 'Utilisateurs', en: 'Users' },
225
+ { key: 'users.subtitle', fr: 'Gérez les accès au dashboard', en: 'Manage dashboard access' },
226
+ { key: 'users.add', fr: 'Ajouter un utilisateur', en: 'Add a user' },
227
+ { key: 'users.none', fr: 'Aucun utilisateur', en: 'No users' },
228
+ { key: 'users.never_connected', fr: 'Jamais connecté', en: 'Never logged in' },
229
+ { key: 'users.role_translator', fr: 'Traducteur', en: 'Translator' },
230
+ { key: 'users.role_moderator', fr: 'Modérateur', en: 'Moderator' },
231
+ { key: 'users.role_admin', fr: 'Admin', en: 'Admin' },
232
+ { key: 'users.inactive', fr: 'Inactif', en: 'Inactive' },
233
+ { key: 'users.no_role', fr: 'Aucun rôle', en: 'No role' },
234
+ // Login
235
+ { key: 'login.title', fr: 'Connexion', en: 'Log in' },
236
+ { key: 'login.email', fr: 'Email', en: 'Email' },
237
+ { key: 'login.password', fr: 'Mot de passe', en: 'Password' },
238
+ { key: 'login.submit', fr: 'Se connecter', en: 'Sign in' },
239
+ { key: 'login.error', fr: 'Identifiants incorrects', en: 'Invalid credentials' },
240
+ // Setup
241
+ { key: 'setup.title', fr: 'Configuration initiale', en: 'Initial setup' },
242
+ { key: 'setup.submit', fr: 'Créer le compte administrateur', en: 'Create administrator account' },
243
+ // Dashboard page
244
+ { key: 'dashboard.title', fr: 'Dashboard', en: 'Dashboard' },
245
+ { key: 'dashboard.total_keys', fr: 'clés totales', en: 'total keys' },
246
+ { key: 'dashboard.unused_keys', fr: 'clés inutilisées', en: 'unused keys' },
247
+ { key: 'dashboard.coverage', fr: 'couverture', en: 'coverage' },
248
+ { key: 'dashboard.recent_activity', fr: 'Activité récente', en: 'Recent activity' },
249
+ { key: 'dashboard.no_activity', fr: 'Aucune activité récente', en: 'No recent activity' },
250
+ // Common
251
+ { key: 'common.cancel', fr: 'Annuler', en: 'Cancel' },
252
+ { key: 'common.save', fr: 'Sauvegarder', en: 'Save' },
253
+ { key: 'common.delete', fr: 'Supprimer', en: 'Delete' },
254
+ { key: 'common.add', fr: 'Ajouter', en: 'Add' },
255
+ { key: 'common.edit', fr: 'Modifier', en: 'Edit' },
256
+ { key: 'common.error', fr: 'Erreur', en: 'Error' },
257
+ { key: 'common.close', fr: 'Fermer', en: 'Close' },
258
+ { key: 'common.copied', fr: 'Copié !', en: 'Copied!' },
259
+ { key: 'common.create', fr: 'Créer', en: 'Create' },
260
+ // Onboarding
261
+ { key: 'onboarding.title', fr: 'Bienvenue sur i18n Dashboard', en: 'Welcome to i18n Dashboard' },
262
+ { key: 'onboarding.subtitle', fr: 'Configurons votre espace de travail en quelques étapes', en: 'Let\'s set up your workspace in a few steps' },
263
+ { key: 'onboarding.step_admin', fr: 'Compte admin', en: 'Admin account' },
264
+ { key: 'onboarding.step_languages', fr: 'Langues interface', en: 'UI Languages' },
265
+ { key: 'onboarding.step_project', fr: 'Premier projet', en: 'First project' },
266
+ { key: 'onboarding.step_done', fr: 'Terminé', en: 'Done' },
267
+ { key: 'onboarding.admin_done', fr: 'Compte administrateur créé avec succès.', en: 'Administrator account created successfully.' },
268
+ { key: 'onboarding.languages_title', fr: 'Langues de l\'interface', en: 'Interface languages' },
269
+ { key: 'onboarding.languages_hint', fr: 'Sélectionnez les langues disponibles pour l\'interface du dashboard.', en: 'Select the available languages for the dashboard interface.' },
270
+ { key: 'onboarding.languages_search', fr: 'Rechercher une langue...', en: 'Search for a language...' },
271
+ { key: 'onboarding.languages_selected', fr: 'langue(s) sélectionnée(s)', en: 'language(s) selected' },
272
+ { key: 'onboarding.languages_default', fr: 'Langue par défaut', en: 'Default language' },
273
+ { key: 'onboarding.project_title', fr: 'Votre premier projet', en: 'Your first project' },
274
+ { key: 'onboarding.project_hint', fr: 'Configurez le projet Vue.js que vous souhaitez gérer.', en: 'Configure the Vue.js project you want to manage.' },
275
+ { key: 'onboarding.project_skip', fr: 'Passer cette étape', en: 'Skip this step' },
276
+ { key: 'onboarding.done_title', fr: 'Tout est prêt !', en: 'All set!' },
277
+ { key: 'onboarding.done_hint', fr: 'Votre dashboard est configuré. Vous pouvez maintenant gérer vos traductions.', en: 'Your dashboard is configured. You can now manage your translations.' },
278
+ { key: 'onboarding.go_to_dashboard', fr: 'Aller au dashboard', en: 'Go to dashboard' },
279
+ { key: 'onboarding.next', fr: 'Suivant', en: 'Next' },
280
+ { key: 'onboarding.previous', fr: 'Précédent', en: 'Previous' },
281
+ { key: 'onboarding.finish', fr: 'Terminer', en: 'Finish' },
282
+ // Onboarding — DB step
283
+ { key: 'onboarding.step_db', fr: 'Base de données', en: 'Database' },
284
+ { key: 'onboarding.db_title', fr: 'Base de données', en: 'Database' },
285
+ { key: 'onboarding.db_subtitle', fr: 'Configurez la connexion à votre base de données. Les valeurs sont pré-remplies depuis votre fichier de configuration.', en: 'Configure your database connection. Values are pre-filled from your config file.' },
286
+ { key: 'onboarding.db_type_label', fr: 'Type de base de données', en: 'Database type' },
287
+ { key: 'onboarding.db_type_sqlite', fr: 'SQLite (fichier local)', en: 'SQLite (local file)' },
288
+ { key: 'onboarding.db_type_postgresql', fr: 'PostgreSQL', en: 'PostgreSQL' },
289
+ { key: 'onboarding.db_type_mysql', fr: 'MySQL / MariaDB', en: 'MySQL / MariaDB' },
290
+ { key: 'onboarding.db_file_label', fr: 'Fichier de base de données', en: 'Database file' },
291
+ { key: 'onboarding.db_create_file', fr: 'Créer', en: 'Create' },
292
+ { key: 'onboarding.db_file_found', fr: 'Fichier trouvé', en: 'File found' },
293
+ { key: 'onboarding.db_file_missing', fr: 'Le fichier n\'existe pas encore. Cliquez sur « Créer » pour le créer.', en: 'The file does not exist yet. Click "Create" to create it.' },
294
+ { key: 'onboarding.db_host', fr: 'Hôte', en: 'Host' },
295
+ { key: 'onboarding.db_port', fr: 'Port', en: 'Port' },
296
+ { key: 'onboarding.db_user', fr: 'Utilisateur', en: 'User' },
297
+ { key: 'onboarding.db_password', fr: 'Mot de passe', en: 'Password' },
298
+ { key: 'onboarding.db_name', fr: 'Base de données', en: 'Database name' },
299
+ { key: 'onboarding.db_test', fr: 'Tester la connexion', en: 'Test connection' },
300
+ { key: 'onboarding.db_test_apply', fr: 'Appliquer', en: 'Apply' },
301
+ { key: 'onboarding.db_connected', fr: 'Connexion OK', en: 'Connection OK' },
302
+ ]
303
+
304
+ async function ensureDashboardUIProject(db: Knex): Promise<void> {
305
+ // Check if system project exists
306
+ let systemProject = await db('projects').where({ is_system: true }).first()
307
+
308
+ if (!systemProject) {
309
+ const [id] = await db('projects').insert({
310
+ name: 'Dashboard UI',
311
+ root_path: '__DASHBOARD_UI__',
312
+ locales_path: '',
313
+ key_separator: '.',
314
+ color: 'violet',
315
+ description: 'Textes de l\'interface du dashboard — gérez les traductions de l\'UI ici.',
316
+ is_system: true,
317
+ })
318
+ systemProject = { id }
319
+ console.log('[i18n-dashboard] Projet système "Dashboard UI" créé.')
320
+ }
321
+
322
+ const projectId = systemProject.id
323
+
324
+ // Load translations from JSON files (prefer file content over hardcoded fallback)
325
+ const frFromFile = readLocaleFile('fr')
326
+ const enFromFile = readLocaleFile('en')
327
+
328
+ // Merge: JSON file takes priority over hardcoded array
329
+ const frMap: Record<string, string> = {}
330
+ const enMap: Record<string, string> = {}
331
+ for (const item of DASHBOARD_UI_STRINGS) {
332
+ frMap[item.key] = item.fr
333
+ enMap[item.key] = item.en
334
+ }
335
+ Object.assign(frMap, frFromFile)
336
+ Object.assign(enMap, enFromFile)
337
+
338
+ // All keys from both sources
339
+ const allKeys = [...new Set([...Object.keys(frMap), ...Object.keys(enMap)])]
340
+
341
+ // Seed EN as the base language (translations for other languages are added via onboarding/auto-translate)
342
+ for (const lang of [{ code: 'en', name: 'English', isDefault: true }, { code: 'fr', name: 'Français', isDefault: false }]) {
343
+ const exists = await db('languages').where({ project_id: projectId, code: lang.code }).first()
344
+ if (!exists) {
345
+ await db('languages').insert({
346
+ project_id: projectId,
347
+ code: lang.code,
348
+ name: lang.name,
349
+ is_default: lang.isDefault,
350
+ })
351
+ }
352
+ }
353
+
354
+ // Seed strings — idempotent: insert missing keys AND missing translations
355
+ for (const key of allKeys) {
356
+ let existingKey = await db('translation_keys').where({ project_id: projectId, key }).first()
357
+ if (!existingKey) {
358
+ const [keyId] = await db('translation_keys').insert({
359
+ project_id: projectId,
360
+ key,
361
+ description: null,
362
+ is_unused: false,
363
+ })
364
+ existingKey = { id: keyId }
365
+ }
366
+
367
+ // Ensure EN and FR translations exist (approved — these are the source of truth)
368
+ for (const [langCode, value] of [['en', enMap[key]], ['fr', frMap[key]]] as [string, string][]) {
369
+ if (!value) continue
370
+ const existingTr = await db('translations')
371
+ .where({ key_id: existingKey.id, language_code: langCode })
372
+ .first()
373
+ if (!existingTr) {
374
+ await db('translations').insert({ key_id: existingKey.id, language_code: langCode, value, status: 'approved' })
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ export async function initDb(): Promise<void> {
381
+ const db = getDb()
382
+
383
+ // ── projects ──────────────────────────────────────────────────────────
384
+ const hasProjects = await db.schema.hasTable('projects')
385
+ if (!hasProjects) {
386
+ await db.schema.createTable('projects', (table) => {
387
+ table.increments('id').primary()
388
+ table.string('name', 200).notNullable()
389
+ table.text('root_path').notNullable().defaultTo('') // absolute path to project root (empty if remote)
390
+ table.text('source_url').nullable() // remote URL for fetching locale JSON files
391
+ table.string('locales_path', 500).defaultTo('src/locales') // relative to root_path
392
+ table.string('key_separator', 10).defaultTo('.')
393
+ table.string('color', 30).defaultTo('primary') // UI accent color
394
+ table.text('description').nullable()
395
+ table.boolean('is_system').defaultTo(false) // reserved for Dashboard UI project
396
+ table.timestamp('created_at').defaultTo(db.fn.now())
397
+ })
398
+ }
399
+
400
+ // ── languages ─────────────────────────────────────────────────────────
401
+ const hasLanguages = await db.schema.hasTable('languages')
402
+ if (!hasLanguages) {
403
+ await db.schema.createTable('languages', (table) => {
404
+ table.increments('id').primary()
405
+ table.integer('project_id').notNullable().references('id').inTable('projects').onDelete('CASCADE')
406
+ table.string('code', 35).notNullable() // BCP 47: up to 35 chars (e.g. zh-Hant-TW, sr-Latn-RS)
407
+ table.string('name', 100).notNullable()
408
+ table.boolean('is_default').defaultTo(false)
409
+ table.timestamp('created_at').defaultTo(db.fn.now())
410
+ table.unique(['project_id', 'code'])
411
+ })
412
+ }
413
+
414
+ // ── translation_keys ──────────────────────────────────────────────────
415
+ const hasKeys = await db.schema.hasTable('translation_keys')
416
+ if (!hasKeys) {
417
+ await db.schema.createTable('translation_keys', (table) => {
418
+ table.increments('id').primary()
419
+ table.integer('project_id').notNullable().references('id').inTable('projects').onDelete('CASCADE')
420
+ table.string('key', 500).notNullable()
421
+ table.text('description').nullable()
422
+ table.boolean('is_unused').defaultTo(false)
423
+ table.timestamp('last_scanned_at').nullable()
424
+ table.timestamp('created_at').defaultTo(db.fn.now())
425
+ table.timestamp('updated_at').defaultTo(db.fn.now())
426
+ table.unique(['project_id', 'key'])
427
+ })
428
+ }
429
+
430
+ // ── translations ──────────────────────────────────────────────────────
431
+ const hasTranslations = await db.schema.hasTable('translations')
432
+ if (!hasTranslations) {
433
+ await db.schema.createTable('translations', (table) => {
434
+ table.increments('id').primary()
435
+ table.integer('key_id').notNullable().references('id').inTable('translation_keys').onDelete('CASCADE')
436
+ table.string('language_code', 10).notNullable()
437
+ table.text('value').nullable()
438
+ table.string('status', 20).defaultTo('draft').nullable()
439
+ table.timestamp('created_at').defaultTo(db.fn.now())
440
+ table.timestamp('updated_at').defaultTo(db.fn.now())
441
+ table.unique(['key_id', 'language_code'])
442
+ })
443
+ }
444
+
445
+ // ── translation_history ───────────────────────────────────────────────
446
+ const hasHistory = await db.schema.hasTable('translation_history')
447
+ if (!hasHistory) {
448
+ await db.schema.createTable('translation_history', (table) => {
449
+ table.increments('id').primary()
450
+ table.integer('translation_id').notNullable().references('id').inTable('translations').onDelete('CASCADE')
451
+ table.text('old_value').nullable()
452
+ table.text('new_value').nullable()
453
+ table.string('changed_by', 100).defaultTo('user')
454
+ table.timestamp('changed_at').defaultTo(db.fn.now())
455
+ })
456
+ }
457
+
458
+ // ── key_usages ────────────────────────────────────────────────────────
459
+ const hasKeyUsages = await db.schema.hasTable('key_usages')
460
+ if (!hasKeyUsages) {
461
+ await db.schema.createTable('key_usages', (table) => {
462
+ table.increments('id').primary()
463
+ table.integer('key_id').notNullable().references('id').inTable('translation_keys').onDelete('CASCADE')
464
+ table.string('file_path', 1000).notNullable()
465
+ table.integer('line_number').nullable()
466
+ table.string('detected_function', 50).nullable()
467
+ table.timestamp('scanned_at').defaultTo(db.fn.now())
468
+ table.index(['key_id'])
469
+ })
470
+ }
471
+
472
+ // ── users ──────────────────────────────────────────────────────────────
473
+ const hasUsers = await db.schema.hasTable('users')
474
+ if (!hasUsers) {
475
+ await db.schema.createTable('users', (table) => {
476
+ table.increments('id').primary()
477
+ table.string('email', 255).notNullable().unique()
478
+ table.string('name', 200).notNullable()
479
+ table.string('password_hash', 255).notNullable()
480
+ table.boolean('is_super_admin').defaultTo(false)
481
+ table.boolean('is_active').defaultTo(true)
482
+ table.timestamp('last_login_at').nullable()
483
+ table.timestamp('created_at').defaultTo(db.fn.now())
484
+ })
485
+ }
486
+
487
+ // ── user_project_roles ─────────────────────────────────────────────────
488
+ // project_id = NULL → role applies to ALL projects (global user)
489
+ const hasUserRoles = await db.schema.hasTable('user_project_roles')
490
+ if (!hasUserRoles) {
491
+ await db.schema.createTable('user_project_roles', (table) => {
492
+ table.increments('id').primary()
493
+ table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')
494
+ table.integer('project_id').nullable().references('id').inTable('projects').onDelete('CASCADE')
495
+ table.string('role', 20).notNullable().defaultTo('translator') // admin | moderator | translator
496
+ table.timestamp('created_at').defaultTo(db.fn.now())
497
+ table.unique(['user_id', 'project_id'])
498
+ })
499
+ }
500
+
501
+ // ── settings (global) ─────────────────────────────────────────────────
502
+ const hasSettings = await db.schema.hasTable('settings')
503
+ if (!hasSettings) {
504
+ await db.schema.createTable('settings', (table) => {
505
+ table.string('key', 100).primary()
506
+ table.text('value').nullable()
507
+ table.timestamp('updated_at').defaultTo(db.fn.now())
508
+ })
509
+ await db('settings').insert([
510
+ { key: 'scan_exclude', value: 'node_modules,dist,.nuxt,.output,.git' },
511
+ { key: 'onboarding_completed', value: 'false' },
512
+ ])
513
+ }
514
+
515
+ // ── migrations for existing databases ─────────────────────────────────
516
+ // Add status column if missing (older DBs)
517
+ await addColumnIfMissing(db, 'translations', 'status', (t) =>
518
+ t.string('status', 20).defaultTo('draft').nullable(),
519
+ )
520
+
521
+ // ── migrate old schema: add project_id to languages / translation_keys ─
522
+ // If tables exist but don't have project_id (pre-multi-project schema),
523
+ // create a default project and assign all existing rows to it.
524
+ const langHasProject = await db.schema.hasColumn('languages', 'project_id')
525
+ const keysHasProject = await db.schema.hasColumn('translation_keys', 'project_id')
526
+
527
+ if (!langHasProject || !keysHasProject) {
528
+ // Ensure at least one project exists to assign rows to
529
+ let defaultProjectId: number
530
+ const firstProject = await db('projects').orderBy('id', 'asc').first()
531
+ if (firstProject) {
532
+ defaultProjectId = firstProject.id
533
+ } else {
534
+ const runtimeCfg = useRuntimeConfig()
535
+ const root = (runtimeCfg.projectRoot as string || '').trim() || process.cwd()
536
+ const [id] = await db('projects').insert({
537
+ name: basename(root) || 'Projet par défaut',
538
+ root_path: resolve(root),
539
+ locales_path: (runtimeCfg.localesPath as string) || 'src/locales',
540
+ key_separator: '.',
541
+ color: 'primary',
542
+ })
543
+ defaultProjectId = id
544
+ console.log(`[i18n-dashboard] Migration: projet par défaut créé (id=${defaultProjectId})`)
545
+ }
546
+
547
+ if (!langHasProject) {
548
+ await db.schema.table('languages', (t) => t.integer('project_id').defaultTo(defaultProjectId))
549
+ await db('languages').whereNull('project_id').update({ project_id: defaultProjectId })
550
+ console.log('[i18n-dashboard] Migration: project_id ajouté à languages')
551
+ }
552
+
553
+ if (!keysHasProject) {
554
+ await db.schema.table('translation_keys', (t) => t.integer('project_id').defaultTo(defaultProjectId))
555
+ await db('translation_keys').whereNull('project_id').update({ project_id: defaultProjectId })
556
+ console.log('[i18n-dashboard] Migration: project_id ajouté à translation_keys')
557
+ }
558
+ }
559
+
560
+ // ── migration: is_system column on projects ────────────────────────────
561
+ await addColumnIfMissing(db, 'projects', 'is_system', (t) =>
562
+ t.boolean('is_system').defaultTo(false),
563
+ )
564
+
565
+ // ── migration: source_url column on projects ───────────────────────────
566
+ await addColumnIfMissing(db, 'projects', 'source_url', (t) =>
567
+ t.text('source_url').nullable(),
568
+ )
569
+
570
+ // ── migration: fallback_code on languages ─────────────────────────────
571
+ await addColumnIfMissing(db, 'languages', 'fallback_code', (t) =>
572
+ t.string('fallback_code', 35).nullable(),
573
+ )
574
+
575
+ // ── migration: enlarge languages.code for BCP 47 (PG / MySQL only) ─────
576
+ // SQLite does not enforce VARCHAR length so no action needed there.
577
+ {
578
+ const client = (db.client as any)?.config?.client ?? ''
579
+ if (client === 'pg' || client === 'postgresql') {
580
+ try {
581
+ await db.raw('ALTER TABLE languages ALTER COLUMN code TYPE varchar(35)')
582
+ } catch { /* already 35 or wider */ }
583
+ } else if (client === 'mysql2' || client === 'mysql') {
584
+ try {
585
+ await db.raw('ALTER TABLE languages MODIFY COLUMN code varchar(35) NOT NULL')
586
+ } catch { /* already done */ }
587
+ }
588
+ }
589
+
590
+ // ── auto-create default project from I18N_PROJECT_ROOT env var ────────
591
+ // Only when explicitly passed by the CLI (not the empty default)
592
+ const config = useRuntimeConfig()
593
+ const projectRoot = (config.projectRoot as string || '').trim()
594
+ if (projectRoot) {
595
+ const resolvedRoot = resolve(projectRoot)
596
+ const existing = await db('projects').where({ root_path: resolvedRoot }).first()
597
+ if (!existing) {
598
+ const projectName = basename(resolvedRoot) || 'Mon Projet'
599
+ const localesPath = (config.localesPath as string) || 'src/locales'
600
+ const keySep = (config.public?.keySeparator as string) || '.'
601
+ await db('projects').insert({
602
+ name: projectName,
603
+ root_path: resolvedRoot,
604
+ locales_path: localesPath,
605
+ key_separator: keySep,
606
+ color: 'primary',
607
+ })
608
+ console.log(`[i18n-dashboard] Projet auto-créé : "${projectName}" → ${resolvedRoot}`)
609
+ }
610
+ }
611
+
612
+ // ── ensure onboarding_completed row exists (migration for older DBs) ───
613
+ const onboardingRow = await db('settings').where({ key: 'onboarding_completed' }).first()
614
+ if (!onboardingRow) {
615
+ await db('settings').insert({ key: 'onboarding_completed', value: 'false' })
616
+ }
617
+
618
+ // ── ensure Dashboard UI system project exists with seeds ───────────────
619
+ await ensureDashboardUIProject(db)
620
+
621
+ // ── project_number_formats ─────────────────────────────────────────────
622
+ const hasNumberFormats = await db.schema.hasTable('project_number_formats')
623
+ if (!hasNumberFormats) {
624
+ await db.schema.createTable('project_number_formats', (table) => {
625
+ table.increments('id').primary()
626
+ table.integer('project_id').notNullable().references('id').inTable('projects').onDelete('CASCADE')
627
+ table.string('locale', 10).notNullable()
628
+ table.string('name', 100).notNullable()
629
+ table.text('options').notNullable().defaultTo('{}') // JSON Intl.NumberFormat options
630
+ table.timestamp('created_at').defaultTo(db.fn.now())
631
+ table.unique(['project_id', 'locale', 'name'])
632
+ })
633
+ }
634
+
635
+ // ── project_datetime_formats ────────────────────────────────────────────
636
+ const hasDatetimeFormats = await db.schema.hasTable('project_datetime_formats')
637
+ if (!hasDatetimeFormats) {
638
+ await db.schema.createTable('project_datetime_formats', (table) => {
639
+ table.increments('id').primary()
640
+ table.integer('project_id').notNullable().references('id').inTable('projects').onDelete('CASCADE')
641
+ table.string('locale', 10).notNullable()
642
+ table.string('name', 100).notNullable()
643
+ table.text('options').notNullable().defaultTo('{}') // JSON Intl.DateTimeFormat options
644
+ table.timestamp('created_at').defaultTo(db.fn.now())
645
+ table.unique(['project_id', 'locale', 'name'])
646
+ })
647
+ }
648
+
649
+ // ── project_modifiers ────────────────────────────────────────────────────
650
+ const hasModifiers = await db.schema.hasTable('project_modifiers')
651
+ if (!hasModifiers) {
652
+ await db.schema.createTable('project_modifiers', (table) => {
653
+ table.increments('id').primary()
654
+ table.integer('project_id').notNullable().references('id').inTable('projects').onDelete('CASCADE')
655
+ table.string('name', 100).notNullable()
656
+ table.text('body').notNullable() // JS arrow function string e.g. "(str) => str.toUpperCase()"
657
+ table.timestamp('created_at').defaultTo(db.fn.now())
658
+ table.unique(['project_id', 'name'])
659
+ })
660
+ }
661
+
662
+ // ── migration: enable_* columns on projects ──────────────────────────────
663
+ await addColumnIfMissing(db, 'projects', 'enable_number_formats', (t) =>
664
+ t.boolean('enable_number_formats').defaultTo(false),
665
+ )
666
+ await addColumnIfMissing(db, 'projects', 'enable_datetime_formats', (t) =>
667
+ t.boolean('enable_datetime_formats').defaultTo(false),
668
+ )
669
+ await addColumnIfMissing(db, 'projects', 'enable_modifiers', (t) =>
670
+ t.boolean('enable_modifiers').defaultTo(false),
671
+ )
672
+ }