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.
- package/LICENSE +21 -0
- package/README.md +715 -0
- package/app.vue +8 -0
- package/assets/css/main.css +21 -0
- package/assets/locales/en.json +380 -0
- package/bin/cli.mjs +279 -0
- package/components/LinkedKeyPicker.vue +135 -0
- package/components/PathPicker.vue +153 -0
- package/components/PluralEditor.vue +295 -0
- package/components/ScanModal.vue +153 -0
- package/components/TranslationHistoryModal.vue +66 -0
- package/components/TranslationRow.vue +541 -0
- package/components/dashboard/WidgetConfigModal.vue +121 -0
- package/components/dashboard/WidgetGrid.vue +190 -0
- package/components/dashboard/WidgetPicker.vue +75 -0
- package/components/dashboard/widgets/ActivityWidget.vue +109 -0
- package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
- package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
- package/components/dashboard/widgets/ReviewWidget.vue +150 -0
- package/components/dashboard/widgets/StatWidget.vue +133 -0
- package/composables/useAuth.ts +72 -0
- package/composables/useConfig.ts +14 -0
- package/composables/useDashboard.ts +89 -0
- package/composables/useFormats.ts +100 -0
- package/composables/useKeys.ts +231 -0
- package/composables/useLanguages.ts +221 -0
- package/composables/useProfile.ts +76 -0
- package/composables/useProject.ts +180 -0
- package/composables/useReview.ts +94 -0
- package/composables/useSettings.ts +30 -0
- package/composables/useStats.ts +16 -0
- package/composables/useT.ts +38 -0
- package/composables/useUsers.ts +101 -0
- package/composables/useWidgetData.ts +50 -0
- package/consts/commons.const.ts +6 -0
- package/consts/dashboard.const.ts +94 -0
- package/consts/languages.const.ts +223 -0
- package/enums/commons.enum.ts +7 -0
- package/i18n-dashboard.config.example.js +40 -0
- package/interfaces/commons.interface.ts +23 -0
- package/interfaces/job.interface.ts +10 -0
- package/interfaces/key.interface.ts +39 -0
- package/interfaces/languages.interface.ts +23 -0
- package/interfaces/project.interface.ts +9 -0
- package/interfaces/scan.interface.ts +12 -0
- package/interfaces/settings.interface.ts +4 -0
- package/interfaces/stat.interface.ts +30 -0
- package/interfaces/translation.interface.ts +11 -0
- package/interfaces/user.interface.ts +24 -0
- package/layouts/auth.vue +5 -0
- package/layouts/default.vue +327 -0
- package/middleware/auth.global.ts +26 -0
- package/nuxt.config.ts +66 -0
- package/package.json +89 -0
- package/pages/index.vue +5 -0
- package/pages/login.vue +74 -0
- package/pages/onboarding.vue +563 -0
- package/pages/projects/[id]/formats/datetime.vue +240 -0
- package/pages/projects/[id]/formats/modifiers.vue +194 -0
- package/pages/projects/[id]/formats/number.vue +250 -0
- package/pages/projects/[id]/index.vue +182 -0
- package/pages/projects/[id]/languages.vue +537 -0
- package/pages/projects/[id]/review.vue +109 -0
- package/pages/projects/[id]/settings.vue +515 -0
- package/pages/projects/[id]/translations/[keyId].vue +642 -0
- package/pages/projects/[id]/translations/index.vue +250 -0
- package/pages/projects/[id]/users.vue +276 -0
- package/pages/projects/index.vue +334 -0
- package/pages/users/[id]/profile.vue +421 -0
- package/pages/users/index.vue +345 -0
- package/plugins/loading.client.ts +3 -0
- package/plugins/ui-i18n.ts +6 -0
- package/server/api/auth/login.post.ts +28 -0
- package/server/api/auth/logout.post.ts +7 -0
- package/server/api/auth/me.get.ts +11 -0
- package/server/api/auth/me.put.ts +31 -0
- package/server/api/auth/password.put.ts +27 -0
- package/server/api/auth/status.get.ts +16 -0
- package/server/api/config.get.ts +10 -0
- package/server/api/dashboard/layout.get.ts +18 -0
- package/server/api/dashboard/layout.post.ts +18 -0
- package/server/api/db-config.get.ts +44 -0
- package/server/api/db-config.post.ts +73 -0
- package/server/api/export.get.ts +64 -0
- package/server/api/formats/datetime/[id].delete.ts +8 -0
- package/server/api/formats/datetime/[id].put.ts +15 -0
- package/server/api/formats/datetime.get.ts +11 -0
- package/server/api/formats/datetime.post.ts +16 -0
- package/server/api/formats/modifiers/[id].delete.ts +8 -0
- package/server/api/formats/modifiers/[id].put.ts +10 -0
- package/server/api/formats/modifiers.get.ts +10 -0
- package/server/api/formats/modifiers.post.ts +14 -0
- package/server/api/formats/number/[id].delete.ts +8 -0
- package/server/api/formats/number/[id].put.ts +15 -0
- package/server/api/formats/number.get.ts +11 -0
- package/server/api/formats/number.post.ts +16 -0
- package/server/api/formats/snippet.get.ts +87 -0
- package/server/api/fs/browse.get.ts +50 -0
- package/server/api/history/[translationId].get.ts +13 -0
- package/server/api/keys/[id].delete.ts +14 -0
- package/server/api/keys/[id].get.ts +41 -0
- package/server/api/keys/[id].patch.ts +20 -0
- package/server/api/keys/index.get.ts +98 -0
- package/server/api/keys/index.post.ts +17 -0
- package/server/api/languages/[code].delete.ts +15 -0
- package/server/api/languages/[id].put.ts +24 -0
- package/server/api/languages/index.get.ts +13 -0
- package/server/api/languages/index.post.ts +42 -0
- package/server/api/onboarding.post.ts +56 -0
- package/server/api/profile.get.ts +81 -0
- package/server/api/project-snapshot.get.ts +73 -0
- package/server/api/project-snapshot.post.ts +160 -0
- package/server/api/projects/[id].delete.ts +13 -0
- package/server/api/projects/[id].put.ts +40 -0
- package/server/api/projects/index.get.ts +19 -0
- package/server/api/projects/index.post.ts +34 -0
- package/server/api/scan.post.ts +165 -0
- package/server/api/settings/index.get.ts +9 -0
- package/server/api/settings/index.post.ts +20 -0
- package/server/api/setup.post.ts +39 -0
- package/server/api/stats/global.get.ts +126 -0
- package/server/api/stats.get.ts +70 -0
- package/server/api/sync.post.ts +179 -0
- package/server/api/translate.post.ts +52 -0
- package/server/api/translations/batch-translate.post.ts +121 -0
- package/server/api/translations/bulk-status.post.ts +24 -0
- package/server/api/translations/index.post.ts +62 -0
- package/server/api/translations/job/[id].get.ts +23 -0
- package/server/api/translations/status.post.ts +30 -0
- package/server/api/translations/translate-all.post.ts +18 -0
- package/server/api/ui-locale.get.ts +39 -0
- package/server/api/users/[id]/profile.get.ts +107 -0
- package/server/api/users/[id]/roles.put.ts +67 -0
- package/server/api/users/[id].delete.ts +36 -0
- package/server/api/users/[id].put.ts +43 -0
- package/server/api/users/index.get.ts +49 -0
- package/server/api/users/index.post.ts +89 -0
- package/server/consts/auto-translate.const.ts +2 -0
- package/server/consts/commons.const.ts +10 -0
- package/server/consts/db.const.ts +3 -0
- package/server/consts/scanner.const.ts +4 -0
- package/server/consts/translation-job.const.ts +8 -0
- package/server/db/index.ts +672 -0
- package/server/enums/auth.enum.ts +5 -0
- package/server/enums/translation.enum.ts +6 -0
- package/server/interfaces/profile.interface.ts +48 -0
- package/server/interfaces/project-config.interface.ts +9 -0
- package/server/interfaces/scanner.interface.ts +18 -0
- package/server/interfaces/translation-job.interface.ts +13 -0
- package/server/middleware/auth.ts +32 -0
- package/server/plugins/db.ts +6 -0
- package/server/routes/locale/[lang].get.ts +179 -0
- package/server/types/auth.type.ts +3 -0
- package/server/utils/auth.util.ts +89 -0
- package/server/utils/auto-translate.util.ts +112 -0
- package/server/utils/lang-api.util.ts +24 -0
- package/server/utils/mailer.util.ts +80 -0
- package/server/utils/project-config.util.ts +37 -0
- package/server/utils/scanner.uti.ts +307 -0
- package/server/utils/translation-job.util.ts +142 -0
- package/services/auth.service.ts +31 -0
- package/services/base.service.ts +140 -0
- package/services/job.service.ts +10 -0
- package/services/key.service.ts +26 -0
- package/services/language.service.ts +26 -0
- package/services/profile.service.ts +14 -0
- package/services/project.service.ts +23 -0
- package/services/scan.service.ts +14 -0
- package/services/settings.service.ts +14 -0
- package/services/stats.service.ts +11 -0
- package/services/translation.service.ts +36 -0
- package/services/user.service.ts +28 -0
- package/tsconfig.json +3 -0
- package/types/commons.type.ts +3 -0
- package/types/dashboard.type.ts +26 -0
- 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>
|