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
package/bin/cli.mjs ADDED
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'child_process'
3
+ import { Command } from 'commander'
4
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
5
+ import { resolve } from 'path'
6
+ import { tmpdir } from 'os'
7
+
8
+ import { _dirname } from '../consts/commons.const.ts'
9
+ import { buildEnv, loadUserConfig } from '../utils/config.util.ts'
10
+
11
+ const packageRoot = resolve(_dirname, '..')
12
+
13
+ const program = new Command()
14
+
15
+ program
16
+ .name('i18n-dashboard')
17
+ .description('Dashboard to manage vue-i18n translation keys')
18
+ .version(JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf-8')).version)
19
+
20
+ // PID file stored per working directory (hashed to avoid conflicts)
21
+ function getPidFile() {
22
+ const cwd = process.cwd().replace(/[/\\:]/g, '_')
23
+ return resolve(tmpdir(), `i18n-dashboard_${cwd}.pid`)
24
+ }
25
+
26
+ // ── start ──────────────────────────────────────────────────────────────────
27
+ program
28
+ .command('start')
29
+ .description('Start the i18n dashboard server')
30
+ .option('-p, --port <port>', 'Port to listen on (default: 3333)')
31
+ .option('--host <host>', 'Host to listen on (default: localhost)')
32
+ .option('-d, --detach', 'Run in background (detached)')
33
+ .action(async (options) => {
34
+ const userConfig = await loadUserConfig()
35
+ const env = buildEnv(userConfig)
36
+
37
+ if (options.port) env.I18N_PORT = options.port
38
+ if (options.host) env.I18N_HOST = options.host
39
+
40
+ const port = env.I18N_PORT || '3333'
41
+ const host = env.I18N_HOST || 'localhost'
42
+
43
+ console.log(`\n🌐 Starting vue-i18n-dashboard on http://${host}:${port}`)
44
+ console.log(`📁 Project root: ${env.I18N_PROJECT_ROOT}\n`)
45
+
46
+ const outputDir = resolve(packageRoot, '.output')
47
+ const isBuilt = existsSync(resolve(outputDir, 'server/index.mjs'))
48
+
49
+ let proc
50
+ if (isBuilt) {
51
+ proc = spawn('node', [resolve(outputDir, 'server/index.mjs')], {
52
+ env: { ...env, PORT: port, HOST: host },
53
+ stdio: options.detach ? 'ignore' : 'inherit',
54
+ cwd: packageRoot,
55
+ detached: options.detach || false,
56
+ })
57
+ } else {
58
+ proc = spawn('npx', ['nuxt', 'dev', '--port', port, '--host', host], {
59
+ env,
60
+ stdio: options.detach ? 'ignore' : 'inherit',
61
+ cwd: packageRoot,
62
+ detached: options.detach || false,
63
+ })
64
+ }
65
+
66
+ // Save PID for stop command
67
+ const pidFile = getPidFile()
68
+ writeFileSync(pidFile, String(proc.pid))
69
+
70
+ if (options.detach) {
71
+ proc.unref()
72
+ console.log(`✅ Dashboard started in background (PID: ${proc.pid})`)
73
+ console.log(` Stop it with: vue-i18n-dashboard stop\n`)
74
+ } else {
75
+ proc.on('exit', (code) => {
76
+ if (existsSync(pidFile)) unlinkSync(pidFile)
77
+ process.exit(code ?? 0)
78
+ })
79
+ // Clean up PID on SIGINT
80
+ process.on('SIGINT', () => {
81
+ if (existsSync(pidFile)) unlinkSync(pidFile)
82
+ proc.kill('SIGINT')
83
+ })
84
+ }
85
+ })
86
+
87
+ // ── stop ───────────────────────────────────────────────────────────────────
88
+ program
89
+ .command('stop')
90
+ .description('Stop the i18n dashboard server')
91
+ .option('-p, --port <port>', 'Port the server is running on (default: 3333)')
92
+ .action(async (options) => {
93
+ const pidFile = getPidFile()
94
+
95
+ if (existsSync(pidFile)) {
96
+ const pid = parseInt(readFileSync(pidFile, 'utf-8').trim())
97
+ try {
98
+ process.kill(pid, 'SIGTERM')
99
+ unlinkSync(pidFile)
100
+ console.log(`\n✅ Dashboard stopped (PID: ${pid})\n`)
101
+ } catch (e) {
102
+ if (e.code === 'ESRCH') {
103
+ console.log('\nProcess was already stopped.')
104
+ } else {
105
+ console.error('Could not stop process:', e.message)
106
+ }
107
+ if (existsSync(pidFile)) unlinkSync(pidFile)
108
+ }
109
+ return
110
+ }
111
+
112
+ // Fallback: try to kill by port using lsof (macOS/Linux)
113
+ const port = options.port || process.env.I18N_PORT || '3333'
114
+ console.log(`\nNo PID file found. Trying to find process on port ${port}...`)
115
+ const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] })
116
+ let pids = ''
117
+ lsof.stdout.on('data', (d) => { pids += d.toString() })
118
+ lsof.on('close', () => {
119
+ const found = pids.trim().split('\n').filter(Boolean)
120
+ if (!found.length) {
121
+ console.log('No dashboard process found on that port.\n')
122
+ return
123
+ }
124
+ for (const p of found) {
125
+ try {
126
+ process.kill(parseInt(p), 'SIGTERM')
127
+ console.log(`✅ Killed PID ${p}\n`)
128
+ } catch (_) {}
129
+ }
130
+ })
131
+ })
132
+
133
+ // ── build ──────────────────────────────────────────────────────────────────
134
+ program
135
+ .command('build')
136
+ .description('Build the dashboard for production (run once after install)')
137
+ .action(async () => {
138
+ console.log('\n🔨 Building vue-i18n-dashboard for production...\n')
139
+ const proc = spawn('npx', ['nuxt', 'build'], {
140
+ env: process.env,
141
+ stdio: 'inherit',
142
+ cwd: packageRoot,
143
+ })
144
+ proc.on('exit', (code) => {
145
+ if (code === 0) {
146
+ console.log('\n✅ Build complete!')
147
+ console.log(' Run "vue-i18n-dashboard start" to launch the dashboard.\n')
148
+ } else {
149
+ console.error('\n❌ Build failed.\n')
150
+ }
151
+ process.exit(code ?? 0)
152
+ })
153
+ })
154
+
155
+ // ── init ───────────────────────────────────────────────────────────────────
156
+ program
157
+ .command('init')
158
+ .description('Initialize configuration file interactively')
159
+ .option('--db <client>', 'Database client: sqlite3 (default), postgresql, mysql')
160
+ .action(async (options) => {
161
+ const { createInterface } = await import('readline')
162
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
163
+ const question = (q) => new Promise((resolve) => rl.question(q, resolve))
164
+
165
+ console.log('\nvue-i18n-dashboard — Initialisation\n')
166
+
167
+ const dbClient = options.db || await question('Base de données [sqlite3/postgresql/mysql] (défaut: sqlite3): ') || 'sqlite3'
168
+ const localesPath = await question('Dossier des locales (défaut: src/locales): ') || 'src/locales'
169
+ const keySeparator = await question('Séparateur de clés (défaut: .): ') || '.'
170
+
171
+ let configContent = `// i18n-dashboard.config.js
172
+ export default {
173
+ port: 3333,
174
+ // projectRoot: './', // Chemin absolu ou relatif vers votre projet Vue
175
+ localesPath: '${localesPath}', // Relatif à projectRoot
176
+ keySeparator: '${keySeparator}',
177
+ apiPath: '/locale/[lang].json',
178
+
179
+ `
180
+
181
+ if (dbClient === 'sqlite3' || dbClient === 'better-sqlite3') {
182
+ const dbPath = await question('Chemin de la base SQLite (défaut: ./i18n-dashboard.db): ') || './i18n-dashboard.db'
183
+ configContent += ` database: {
184
+ client: 'better-sqlite3',
185
+ connection: '${dbPath}',
186
+ },
187
+
188
+ `
189
+ } else if (dbClient === 'postgresql' || dbClient === 'pg') {
190
+ const host = await question('Host PostgreSQL (défaut: localhost): ') || 'localhost'
191
+ const port = await question('Port PostgreSQL (défaut: 5432): ') || '5432'
192
+ const user = await question('Utilisateur PostgreSQL: ')
193
+ const password = await question('Mot de passe PostgreSQL: ')
194
+ const database = await question('Base de données (défaut: i18n_dashboard): ') || 'i18n_dashboard'
195
+ configContent += ` database: {
196
+ client: 'pg',
197
+ connection: {
198
+ host: '${host}',
199
+ port: ${port},
200
+ user: '${user}',
201
+ password: '${password}',
202
+ database: '${database}',
203
+ },
204
+ },
205
+
206
+ `
207
+ } else if (dbClient === 'mysql' || dbClient === 'mysql2') {
208
+ const host = await question('Host MySQL (défaut: localhost): ') || 'localhost'
209
+ const port = await question('Port MySQL (défaut: 3306): ') || '3306'
210
+ const user = await question('Utilisateur MySQL: ')
211
+ const password = await question('Mot de passe MySQL: ')
212
+ const database = await question('Base de données (défaut: i18n_dashboard): ') || 'i18n_dashboard'
213
+ configContent += ` database: {
214
+ client: 'mysql2',
215
+ connection: {
216
+ host: '${host}',
217
+ port: ${port},
218
+ user: '${user}',
219
+ password: '${password}',
220
+ database: '${database}',
221
+ },
222
+ },
223
+
224
+ `
225
+ }
226
+
227
+ const googleApiKey = await question('Clé API Google Translate (optionnel, laisser vide pour le tier gratuit): ')
228
+ if (googleApiKey) {
229
+ configContent += ` googleTranslate: {
230
+ apiKey: '${googleApiKey}',
231
+ },
232
+
233
+ `
234
+ }
235
+
236
+ configContent += `}`
237
+
238
+ writeFileSync(resolve(process.cwd(), 'i18n-dashboard.config.js'), configContent)
239
+
240
+ console.log('\n✅ Fichier de configuration créé : i18n-dashboard.config.js')
241
+ console.log(' Lancez le dashboard avec : vue-i18n-dashboard start\n')
242
+
243
+ rl.close()
244
+ })
245
+
246
+ // ── sync (CLI shortcut) ────────────────────────────────────────────────────
247
+ program
248
+ .command('sync')
249
+ .description('Sync JSON locale files to the database (dashboard must be running)')
250
+ .option('-p, --port <port>', 'Port the dashboard is running on (default: 3333)')
251
+ .action(async (options) => {
252
+ const port = options.port || process.env.I18N_PORT || '3333'
253
+ console.log(`\nSyncing locale files via http://localhost:${port}...`)
254
+ try {
255
+ // We need the project_id — fetch projects first
256
+ const projectsRes = await fetch(`http://localhost:${port}/api/projects`)
257
+ const projects = await projectsRes.json()
258
+ if (!projects?.length) {
259
+ console.error('No projects found. Add a project first via the dashboard UI.\n')
260
+ return
261
+ }
262
+ // Use the first project (or the one matching CWD)
263
+ const cwd = process.cwd()
264
+ const project = projects.find((p) => p.root_path === cwd) || projects[0]
265
+ console.log(`Using project: ${project.name} (${project.root_path})`)
266
+
267
+ const res = await fetch(`http://localhost:${port}/api/sync`, {
268
+ method: 'POST',
269
+ headers: { 'Content-Type': 'application/json' },
270
+ body: JSON.stringify({ project_id: project.id }),
271
+ })
272
+ const data = await res.json()
273
+ console.log(`✅ Sync done: ${data.added} ajoutées · ${data.updated} mises à jour · ${data.total} total\n`)
274
+ } catch (e) {
275
+ console.error('Could not connect to dashboard. Is it running?\n vue-i18n-dashboard start\n')
276
+ }
277
+ })
278
+
279
+ program.parse()
@@ -0,0 +1,135 @@
1
+ <template>
2
+ <UTooltip :text="t('key.link_key_tooltip', 'Link a key (@:key) with optional modifier')">
3
+ <UButton
4
+ icon="i-heroicons-link"
5
+ size="xs"
6
+ color="neutral"
7
+ variant="soft"
8
+ class="shrink-0"
9
+ @click="openModal"
10
+ />
11
+ </UTooltip>
12
+
13
+ <UModal v-model:open="open" :title="t('key.link_key_title', 'Link a key')" :ui="{ width: 'sm:max-w-lg' }">
14
+ <template #body>
15
+ <div class="space-y-4">
16
+ <!-- Modifier selector -->
17
+ <div class="flex gap-1.5 flex-wrap">
18
+ <button
19
+ v-for="mod in modifiers"
20
+ :key="mod.value"
21
+ class="px-2.5 py-1 rounded-lg border text-xs font-mono transition-colors"
22
+ :class="selectedModifier === mod.value
23
+ ? 'bg-violet-50 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 border-violet-300 dark:border-violet-600'
24
+ : 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-gray-300'"
25
+ @click="selectedModifier = mod.value"
26
+ >
27
+ {{ mod.syntax }}
28
+ </button>
29
+ </div>
30
+
31
+ <!-- Search -->
32
+ <UInput
33
+ ref="searchInput"
34
+ v-model="search"
35
+ icon="i-heroicons-magnifying-glass"
36
+ :placeholder="t('key.search_placeholder', 'Search for a key...')"
37
+ class="w-full"
38
+ @input="onSearch"
39
+ />
40
+
41
+ <!-- Key list -->
42
+ <div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
43
+ <div v-if="loading" class="py-8 text-center">
44
+ <UIcon name="i-heroicons-arrow-path" class="animate-spin text-gray-400 text-lg" />
45
+ </div>
46
+ <div v-else-if="!keys.length" class="py-8 text-center text-sm text-gray-400">
47
+ {{ t('key.none_found', 'No key found') }}
48
+ </div>
49
+ <div v-else class="max-h-72 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
50
+ <button
51
+ v-for="key in keys"
52
+ :key="key.id"
53
+ class="w-full text-left px-3 py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/60 group"
54
+ @click="selectKey(key.key)"
55
+ >
56
+ <span class="text-sm font-mono text-gray-700 dark:text-gray-300 truncate">{{ key.key }}</span>
57
+ <code class="text-xs text-violet-500 dark:text-violet-400 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
58
+ {{ previewSyntax }}{{ key.key }}
59
+ </code>
60
+ </button>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </template>
65
+ </UModal>
66
+ </template>
67
+
68
+ <script setup lang="ts">
69
+ const { t } = useT()
70
+
71
+ const props = defineProps<{
72
+ projectId?: number
73
+ }>()
74
+
75
+ const emit = defineEmits<{
76
+ select: [value: string]
77
+ }>()
78
+
79
+ const open = ref(false)
80
+ const search = ref('')
81
+ const loading = ref(false)
82
+ const keys = ref<Array<{ id: number; key: string }>>([])
83
+ const selectedModifier = ref<string>('')
84
+ const searchInput = ref()
85
+
86
+ const modifiers = computed(() => [
87
+ { value: '', syntax: '@:key' },
88
+ { value: 'lower', syntax: '@.lower:key' },
89
+ { value: 'upper', syntax: '@.upper:key' },
90
+ { value: 'capitalize', syntax: '@.capitalize:key' },
91
+ ])
92
+
93
+ const previewSyntax = computed(() =>
94
+ selectedModifier.value ? `@.${selectedModifier.value}:` : '@:',
95
+ )
96
+
97
+ function openModal() {
98
+ open.value = true
99
+ search.value = ''
100
+ selectedModifier.value = ''
101
+ fetchKeys()
102
+ nextTick(() => searchInput.value?.inputRef?.focus())
103
+ }
104
+
105
+ let searchTimeout: ReturnType<typeof setTimeout>
106
+ function onSearch() {
107
+ clearTimeout(searchTimeout)
108
+ searchTimeout = setTimeout(fetchKeys, 200)
109
+ }
110
+
111
+ async function fetchKeys() {
112
+ if (!props.projectId) return
113
+ loading.value = true
114
+ try {
115
+ const res = await $fetch<{ data: Array<{ id: number; key: string }> }>('/api/keys', {
116
+ query: {
117
+ project_id: props.projectId,
118
+ search: search.value || undefined,
119
+ limit: 50,
120
+ page: 1,
121
+ },
122
+ })
123
+ keys.value = res.data
124
+ } catch {
125
+ keys.value = []
126
+ } finally {
127
+ loading.value = false
128
+ }
129
+ }
130
+
131
+ function selectKey(key: string) {
132
+ emit('select', `${previewSyntax.value}${key}`)
133
+ open.value = false
134
+ }
135
+ </script>
@@ -0,0 +1,153 @@
1
+ <template>
2
+ <div class="flex gap-2">
3
+ <UInput
4
+ :model-value="modelValue"
5
+ class="flex-1 font-mono text-sm"
6
+ :placeholder="placeholder || '/path/to/project'"
7
+ @update:model-value="$emit('update:modelValue', $event)"
8
+ />
9
+ <UButton
10
+ icon="i-heroicons-folder-open"
11
+ color="neutral"
12
+ variant="outline"
13
+ @click="openBrowser"
14
+ />
15
+ </div>
16
+
17
+ <UModal v-model:open="open" :title="t('pathpicker.title', 'Select a folder')" :ui="{ width: 'sm:max-w-xl' }">
18
+ <template #body>
19
+ <div class="space-y-3">
20
+
21
+ <!-- Breadcrumbs + home -->
22
+ <div class="flex items-center gap-1 flex-wrap min-h-6">
23
+ <UButton
24
+ icon="i-heroicons-home"
25
+ color="neutral"
26
+ variant="ghost"
27
+ size="xs"
28
+ @click="browse(data?.home ?? '')"
29
+ />
30
+ <template v-for="(crumb, i) in data?.breadcrumbs" :key="crumb.path">
31
+ <UIcon name="i-heroicons-chevron-right" class="text-gray-400 text-xs shrink-0" />
32
+ <button
33
+ class="text-xs px-1.5 py-0.5 rounded transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 font-mono"
34
+ :class="i === (data?.breadcrumbs.length ?? 0) - 1
35
+ ? 'text-gray-900 dark:text-white font-semibold'
36
+ : 'text-gray-500 dark:text-gray-400'"
37
+ @click="browse(crumb.path)"
38
+ >
39
+ {{ crumb.name }}
40
+ </button>
41
+ </template>
42
+ </div>
43
+
44
+ <!-- Directory list -->
45
+ <div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
46
+ <!-- Up -->
47
+ <button
48
+ v-if="data?.parent"
49
+ class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800 transition-colors"
50
+ @click="browse(data.parent)"
51
+ >
52
+ <UIcon name="i-heroicons-arrow-up" class="text-gray-400 shrink-0" />
53
+ <span class="font-mono">../</span>
54
+ </button>
55
+
56
+ <!-- Loading -->
57
+ <div v-if="loading" class="py-8 text-center">
58
+ <UIcon name="i-heroicons-arrow-path" class="animate-spin text-gray-400 text-lg" />
59
+ </div>
60
+
61
+ <!-- Empty -->
62
+ <div v-else-if="!data?.entries.length" class="py-8 text-center text-sm text-gray-400">
63
+ {{ t('pathpicker.empty', 'No subfolder') }}
64
+ </div>
65
+
66
+ <!-- Entries -->
67
+ <div v-else class="max-h-72 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
68
+ <button
69
+ v-for="entry in data.entries"
70
+ :key="entry.path"
71
+ class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-left hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors group"
72
+ @click="browse(entry.path)"
73
+ >
74
+ <UIcon name="i-heroicons-folder" class="text-amber-400 shrink-0" />
75
+ <span class="font-mono text-gray-700 dark:text-gray-300 flex-1 truncate">{{ entry.name }}</span>
76
+ <UIcon name="i-heroicons-chevron-right" class="text-gray-300 dark:text-gray-600 shrink-0 group-hover:text-gray-400 transition-colors" />
77
+ </button>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Current selection -->
82
+ <div class="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 flex items-center gap-2">
83
+ <UIcon name="i-heroicons-map-pin" class="text-primary-500 shrink-0 text-sm" />
84
+ <code class="text-xs font-mono text-gray-700 dark:text-gray-300 flex-1 truncate">{{ data?.current ?? '…' }}</code>
85
+ </div>
86
+
87
+ <p v-if="browseError" class="text-xs text-red-500">{{ browseError }}</p>
88
+ </div>
89
+ </template>
90
+
91
+ <template #footer>
92
+ <div class="flex justify-end gap-2">
93
+ <UButton color="neutral" variant="ghost" @click="open = false">
94
+ {{ t('common.cancel', 'Cancel') }}
95
+ </UButton>
96
+ <UButton icon="i-heroicons-check" :disabled="!data?.current" @click="select">
97
+ {{ t('pathpicker.select', 'Select this folder') }}
98
+ </UButton>
99
+ </div>
100
+ </template>
101
+ </UModal>
102
+ </template>
103
+
104
+ <script setup lang="ts">
105
+ const { t } = useT()
106
+
107
+ const props = defineProps<{
108
+ modelValue: string
109
+ placeholder?: string
110
+ }>()
111
+
112
+ const emit = defineEmits<{
113
+ 'update:modelValue': [value: string]
114
+ }>()
115
+
116
+ const open = ref(false)
117
+ const loading = ref(false)
118
+ const browseError = ref('')
119
+ const data = ref<{
120
+ current: string
121
+ parent: string | null
122
+ home: string
123
+ breadcrumbs: { name: string; path: string }[]
124
+ entries: { name: string; path: string }[]
125
+ } | null>(null)
126
+
127
+ async function openBrowser() {
128
+ open.value = true
129
+ // Start from modelValue if set, otherwise let the server default to home
130
+ await browse(props.modelValue || '')
131
+ }
132
+
133
+ async function browse(path: string) {
134
+ loading.value = true
135
+ browseError.value = ''
136
+ try {
137
+ data.value = await $fetch('/api/fs/browse', {
138
+ query: path ? { path } : {},
139
+ })
140
+ } catch (e: any) {
141
+ browseError.value = e?.data?.message ?? 'Cannot browse this path'
142
+ } finally {
143
+ loading.value = false
144
+ }
145
+ }
146
+
147
+ function select() {
148
+ if (data.value?.current) {
149
+ emit('update:modelValue', data.value.current)
150
+ }
151
+ open.value = false
152
+ }
153
+ </script>