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