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,295 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-4">
|
|
3
|
+
|
|
4
|
+
<!-- Template picker -->
|
|
5
|
+
<div>
|
|
6
|
+
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{{ t('plural.template_title', 'Pluralization template') }}</p>
|
|
7
|
+
<div class="grid grid-cols-1 gap-1.5">
|
|
8
|
+
<button
|
|
9
|
+
v-for="tpl in TEMPLATES"
|
|
10
|
+
:key="tpl.id"
|
|
11
|
+
class="flex items-start gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors"
|
|
12
|
+
:class="activeTplId === tpl.id
|
|
13
|
+
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700'
|
|
14
|
+
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
|
15
|
+
@click="applyTemplate(tpl)"
|
|
16
|
+
>
|
|
17
|
+
<div class="flex-1 min-w-0">
|
|
18
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
19
|
+
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ tpl.name }}</span>
|
|
20
|
+
<span class="text-xs text-gray-400">{{ tpl.langs }}</span>
|
|
21
|
+
</div>
|
|
22
|
+
<code class="text-xs font-mono text-green-600 dark:text-green-400 mt-0.5 block truncate">{{ tpl.preview }}</code>
|
|
23
|
+
</div>
|
|
24
|
+
<UBadge size="xs" :color="activeTplId === tpl.id ? 'success' : 'neutral'" variant="soft">
|
|
25
|
+
{{ tpl.forms.length }} {{ t('plural.forms', 'forms') }}
|
|
26
|
+
</UBadge>
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<!-- Implicit params hint -->
|
|
32
|
+
<div class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
|
33
|
+
<UIcon name="i-heroicons-information-circle" class="shrink-0 mt-0.5" />
|
|
34
|
+
<div>
|
|
35
|
+
<span class="font-semibold">{{ t('plural.implicit_params_title', 'Implicit parameters:') }}</span>
|
|
36
|
+
<code class="font-mono mx-1 bg-amber-100 dark:bg-amber-900/40 px-1 rounded">{count}</code> {{ t('plural.and', 'and') }}
|
|
37
|
+
<code class="font-mono mx-1 bg-amber-100 dark:bg-amber-900/40 px-1 rounded">{n}</code>
|
|
38
|
+
{{ t('plural.implicit_params_hint', 'are automatically available in any pluralized key — you don\'t need to pass them explicitly.') }}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Form fields -->
|
|
43
|
+
<div class="space-y-2">
|
|
44
|
+
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('plural.forms_title', 'Forms') }}</p>
|
|
45
|
+
<div
|
|
46
|
+
v-for="(form, i) in forms"
|
|
47
|
+
:key="i"
|
|
48
|
+
class="flex items-start gap-3"
|
|
49
|
+
>
|
|
50
|
+
<!-- Label column -->
|
|
51
|
+
<div class="shrink-0 w-28 pt-1.5 space-y-0.5">
|
|
52
|
+
<div class="flex items-center gap-1">
|
|
53
|
+
<span class="text-xs font-mono font-bold text-green-600 dark:text-green-400">
|
|
54
|
+
[{{ i }}]
|
|
55
|
+
</span>
|
|
56
|
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formLabel(i) }}</span>
|
|
57
|
+
</div>
|
|
58
|
+
<p class="text-xs text-gray-400 font-mono">count={{ triggerCount(i) }}</p>
|
|
59
|
+
<button
|
|
60
|
+
v-if="forms.length > 2"
|
|
61
|
+
class="text-xs text-gray-300 hover:text-red-400 transition-colors flex items-center gap-0.5"
|
|
62
|
+
@click="removeForm(i)"
|
|
63
|
+
>
|
|
64
|
+
<UIcon name="i-heroicons-trash" class="text-xs" />
|
|
65
|
+
{{ t('plural.remove', 'Remove') }}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<!-- Textarea + implicit param chips -->
|
|
70
|
+
<div class="flex-1 space-y-1">
|
|
71
|
+
<UTextarea
|
|
72
|
+
v-model="forms[i]"
|
|
73
|
+
:rows="2"
|
|
74
|
+
class="w-full text-sm"
|
|
75
|
+
:placeholder="formPlaceholder(i)"
|
|
76
|
+
@input="emitValue"
|
|
77
|
+
/>
|
|
78
|
+
<!-- Implicit param quick-insert -->
|
|
79
|
+
<div class="flex gap-1">
|
|
80
|
+
<button
|
|
81
|
+
v-for="p in IMPLICIT_PARAMS"
|
|
82
|
+
:key="p"
|
|
83
|
+
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-700 hover:bg-amber-100 transition-colors"
|
|
84
|
+
@mousedown.prevent="insertInForm(i, `{${p}}`)"
|
|
85
|
+
>
|
|
86
|
+
<UIcon name="i-heroicons-cursor-arrow-rays" class="text-xs mr-0.5 opacity-60" />
|
|
87
|
+
{{ `{/${p}/}` }}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
class="text-xs text-green-600 dark:text-green-400 hover:text-green-700 flex items-center gap-1 transition-colors mt-1"
|
|
95
|
+
@click="addForm"
|
|
96
|
+
>
|
|
97
|
+
<UIcon name="i-heroicons-plus-circle" class="text-sm" />
|
|
98
|
+
{{ t('plural.add_form', 'Add a form') }}
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<!-- Live preview -->
|
|
103
|
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
|
|
104
|
+
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('formats.preview', 'Preview') }}</p>
|
|
105
|
+
<div class="grid grid-cols-3 gap-2">
|
|
106
|
+
<div
|
|
107
|
+
v-for="n in PREVIEW_COUNTS"
|
|
108
|
+
:key="n"
|
|
109
|
+
class="bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 px-2.5 py-2"
|
|
110
|
+
>
|
|
111
|
+
<p class="text-xs text-gray-400 mb-1">count = <strong class="text-gray-600 dark:text-gray-300">{{ n }}</strong></p>
|
|
112
|
+
<p class="text-sm text-gray-700 dark:text-gray-300 font-medium min-h-[1.25rem]">
|
|
113
|
+
<span v-if="resolvePreview(n)">{{ resolvePreview(n) }}</span>
|
|
114
|
+
<span v-else class="text-gray-300 italic text-xs">{{ t('plural.empty', 'empty') }}</span>
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<p class="text-xs text-gray-400">
|
|
119
|
+
{{ t('plural.selection_rule', 'Selection rule: English rule by default (count=1 → form [1] for 2 forms, count=0 → [0], count=1 → [1], count≥2 → [2] for 3 forms).') }}
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<!-- Raw value -->
|
|
124
|
+
<div class="flex items-center gap-2 text-xs text-gray-400">
|
|
125
|
+
<span class="shrink-0">{{ t('plural.raw_value', 'Raw value:') }}</span>
|
|
126
|
+
<code class="font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded flex-1 truncate text-gray-600 dark:text-gray-300">
|
|
127
|
+
{{ joinedValue }}
|
|
128
|
+
</code>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<script setup lang="ts">
|
|
134
|
+
const { t } = useT()
|
|
135
|
+
|
|
136
|
+
const props = defineProps<{
|
|
137
|
+
modelValue: string
|
|
138
|
+
}>()
|
|
139
|
+
|
|
140
|
+
const emit = defineEmits<{
|
|
141
|
+
'update:modelValue': [value: string]
|
|
142
|
+
}>()
|
|
143
|
+
|
|
144
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
const PLURAL_SEP = ' | '
|
|
147
|
+
const IMPLICIT_PARAMS = ['count', 'n']
|
|
148
|
+
const PREVIEW_COUNTS = [0, 1, 5]
|
|
149
|
+
|
|
150
|
+
const TEMPLATES = computed(() => [
|
|
151
|
+
{
|
|
152
|
+
id: '2-standard',
|
|
153
|
+
name: t('plural.tpl_2_standard', '2 forms — standard'),
|
|
154
|
+
langs: t('plural.tpl_2_langs', 'English, German, Dutch…'),
|
|
155
|
+
preview: 'car | cars',
|
|
156
|
+
forms: ['car', 'cars'],
|
|
157
|
+
// English 2-form rule: 1→[0], else→[1]
|
|
158
|
+
rule: (n: number, len: number) => n === 1 ? 0 : Math.min(1, len - 1),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: '3-zero-one-many',
|
|
162
|
+
name: t('plural.tpl_3_zero', '3 forms — zero / one / many'),
|
|
163
|
+
langs: t('plural.tpl_3_langs', 'French, Spanish, Italian…'),
|
|
164
|
+
preview: 'no car | {count} car | {count} cars',
|
|
165
|
+
forms: ['no car', '{count} car', '{count} cars'],
|
|
166
|
+
// French 3-form rule: 0→[0], 1→[1], else→[2]
|
|
167
|
+
rule: (n: number, len: number) => n === 0 ? 0 : n === 1 ? 1 : Math.min(2, len - 1),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: '4-slavic',
|
|
171
|
+
name: t('plural.tpl_4_slavic', '4 forms — Slavic languages'),
|
|
172
|
+
langs: t('plural.tpl_4_langs', 'Russian, Polish, Ukrainian…'),
|
|
173
|
+
preview: '0 машин | {n} машина | {n} машины | {n} машин',
|
|
174
|
+
forms: ['0 машин', '{n} машина', '{n} машины', '{n} машин'],
|
|
175
|
+
// Simplified Slavic rule
|
|
176
|
+
rule: (n: number, len: number) => {
|
|
177
|
+
if (n === 0) return 0
|
|
178
|
+
const teen = n > 10 && n < 20
|
|
179
|
+
const end = n % 10
|
|
180
|
+
if (!teen && end === 1) return Math.min(1, len - 1)
|
|
181
|
+
if (!teen && end >= 2 && end <= 4) return Math.min(2, len - 1)
|
|
182
|
+
return Math.min(3, len - 1)
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'custom',
|
|
187
|
+
name: t('plural.tpl_custom', 'Custom'),
|
|
188
|
+
langs: t('plural.tpl_custom_hint', 'Define your own forms'),
|
|
189
|
+
preview: t('plural.tpl_custom_preview', 'form 1 | form 2 | …'),
|
|
190
|
+
forms: ['', ''],
|
|
191
|
+
rule: (n: number, len: number) => n === 1 ? 0 : Math.min(1, len - 1),
|
|
192
|
+
},
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const forms = ref<string[]>(['', ''])
|
|
198
|
+
const activeTplId = ref<string>('2-standard')
|
|
199
|
+
|
|
200
|
+
// Active template rule for preview
|
|
201
|
+
const activeRule = computed(
|
|
202
|
+
() => TEMPLATES.value.find(t => t.id === activeTplId.value)?.rule
|
|
203
|
+
?? TEMPLATES.value[0].rule,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
// ── Init from modelValue ──────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
watch(
|
|
209
|
+
() => props.modelValue,
|
|
210
|
+
(val) => {
|
|
211
|
+
if (!val) return
|
|
212
|
+
const split = val.split(PLURAL_SEP)
|
|
213
|
+
forms.value = split
|
|
214
|
+
// Auto-detect template
|
|
215
|
+
if (split.length === 4) activeTplId.value = '4-slavic'
|
|
216
|
+
else if (split.length === 3) activeTplId.value = '3-zero-one-many'
|
|
217
|
+
else activeTplId.value = '2-standard'
|
|
218
|
+
},
|
|
219
|
+
{ immediate: true },
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
// ── Computed ──────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const joinedValue = computed(() => forms.value.join(PLURAL_SEP))
|
|
225
|
+
|
|
226
|
+
// ── Label helpers ─────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function formLabel(index: number): string {
|
|
229
|
+
const len = forms.value.length
|
|
230
|
+
if (len === 2) return index === 0 ? t('plural.singular', 'singular') : t('plural.plural_form', 'plural')
|
|
231
|
+
if (len === 3) return [t('plural.zero', 'zero'), t('plural.singular', 'singular'), t('plural.plural_form', 'plural')][index] ?? `${t('plural.form', 'form')} ${index}`
|
|
232
|
+
if (len === 4) return [t('plural.zero', 'zero'), t('plural.singular', 'singular'), t('plural.few', 'few'), t('plural.plural_form', 'plural')][index] ?? `${t('plural.form', 'form')} ${index}`
|
|
233
|
+
return `${t('plural.form', 'form')} ${index}`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function triggerCount(index: number): string {
|
|
237
|
+
const len = forms.value.length
|
|
238
|
+
if (len === 2) return index === 0 ? '1' : '0, 2, 3…'
|
|
239
|
+
if (len === 3) return ['0', '1', '2, 3…'][index] ?? '…'
|
|
240
|
+
if (len === 4) return ['0', 'x1 (excl. 11)', 'x2–4', t('plural.others', 'others')][index] ?? '…'
|
|
241
|
+
return '…'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formPlaceholder(index: number): string {
|
|
245
|
+
const len = forms.value.length
|
|
246
|
+
if (len === 2) return index === 0 ? 'car' : 'cars'
|
|
247
|
+
if (len === 3) {
|
|
248
|
+
return ['no car', 'one car', '{count} cars'][index] ?? ''
|
|
249
|
+
}
|
|
250
|
+
return `${t('plural.form', 'form')} ${index + 1}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Preview ───────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function resolvePreview(n: number): string {
|
|
256
|
+
const len = forms.value.length
|
|
257
|
+
if (!len) return ''
|
|
258
|
+
const idx = activeRule.value(n, len)
|
|
259
|
+
const raw = forms.value[idx] ?? forms.value[len - 1] ?? ''
|
|
260
|
+
return raw.replace(/\{count\}/g, String(n)).replace(/\{n\}/g, String(n))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Actions ───────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
function applyTemplate(tpl: ReturnType<typeof TEMPLATES.value[0]['rule']> extends Function ? any : any) {
|
|
266
|
+
activeTplId.value = tpl.id
|
|
267
|
+
if (tpl.id !== 'custom') {
|
|
268
|
+
forms.value = [...tpl.forms]
|
|
269
|
+
} else {
|
|
270
|
+
forms.value = ['', '']
|
|
271
|
+
}
|
|
272
|
+
emitValue()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function addForm() {
|
|
276
|
+
forms.value.push('')
|
|
277
|
+
activeTplId.value = 'custom'
|
|
278
|
+
emitValue()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function removeForm(index: number) {
|
|
282
|
+
if (forms.value.length <= 2) return
|
|
283
|
+
forms.value.splice(index, 1)
|
|
284
|
+
emitValue()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function insertInForm(formIndex: number, text: string) {
|
|
288
|
+
forms.value[formIndex] = (forms.value[formIndex] ?? '') + text
|
|
289
|
+
emitValue()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function emitValue() {
|
|
293
|
+
emit('update:modelValue', joinedValue.value)
|
|
294
|
+
}
|
|
295
|
+
</script>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<UModal v-model:open="open" :title="t('scan.modal_title', 'Scan project')" :ui="{ width: 'sm:max-w-lg' }">
|
|
3
|
+
<template #body>
|
|
4
|
+
<div class="space-y-5">
|
|
5
|
+
|
|
6
|
+
<!-- Mode tabs -->
|
|
7
|
+
<div class="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
|
8
|
+
<button
|
|
9
|
+
v-for="m in modes"
|
|
10
|
+
:key="m.value"
|
|
11
|
+
class="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
|
12
|
+
:class="mode === m.value
|
|
13
|
+
? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm'
|
|
14
|
+
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
|
15
|
+
@click="mode = m.value"
|
|
16
|
+
>
|
|
17
|
+
<UIcon :name="m.icon" class="text-sm" />
|
|
18
|
+
{{ m.label }}
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<!-- Local mode -->
|
|
23
|
+
<div v-if="mode === 'local'" class="space-y-3">
|
|
24
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
25
|
+
{{ t('scan.local_hint', 'Select the root folder of your Vue.js project. The scanner will detect all $t(), t(), <i18n-t> and v-t usages.') }}
|
|
26
|
+
</p>
|
|
27
|
+
<UFormField :label="t('scan.local_path_label', 'Project root folder')">
|
|
28
|
+
<PathPicker v-model="localPath" class="w-full" />
|
|
29
|
+
</UFormField>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<!-- URL mode -->
|
|
33
|
+
<div v-if="mode === 'url'" class="space-y-3">
|
|
34
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
35
|
+
{{ t('scan.url_hint', 'Enter the base URL of your app. The scanner will fetch each configured locale file (en.json, fr.json…) and import all keys it finds.') }}
|
|
36
|
+
</p>
|
|
37
|
+
<UFormField :label="t('scan.url_label', 'Base URL')" :hint="t('scan.url_hint2', 'Example: https://my-app.com')">
|
|
38
|
+
<UInput v-model="remoteUrl" class="w-full" placeholder="https://my-app.com" />
|
|
39
|
+
</UFormField>
|
|
40
|
+
<div v-if="!project?.languages?.length" class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
|
41
|
+
<UIcon name="i-heroicons-exclamation-triangle" class="shrink-0 mt-0.5" />
|
|
42
|
+
{{ t('scan.url_no_langs', 'No languages configured on this project yet. Add languages first so the scanner knows which locale files to fetch.') }}
|
|
43
|
+
</div>
|
|
44
|
+
<div v-else class="flex flex-wrap gap-1.5">
|
|
45
|
+
<UBadge v-for="lang in project.languages" :key="lang.code" color="neutral" variant="soft" size="xs">
|
|
46
|
+
<UIcon name="i-heroicons-document-text" class="mr-1 opacity-60" />
|
|
47
|
+
{{ lang.code }}.json
|
|
48
|
+
</UBadge>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Results -->
|
|
53
|
+
<div v-if="result" class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
|
|
54
|
+
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('scan.results', 'Results') }}</p>
|
|
55
|
+
<div class="grid grid-cols-2 gap-2">
|
|
56
|
+
<div class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
|
|
57
|
+
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ result.keysFound ?? result.keysImported }}</p>
|
|
58
|
+
<p class="text-xs text-gray-400">{{ t('scan.keys_found', 'keys found') }}</p>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
|
|
61
|
+
<p class="text-xl font-bold text-green-600 dark:text-green-400">{{ result.keysAdded }}</p>
|
|
62
|
+
<p class="text-xs text-gray-400">{{ t('scan.keys_added', 'new keys') }}</p>
|
|
63
|
+
</div>
|
|
64
|
+
<div v-if="result.unusedKeys !== undefined" class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
|
|
65
|
+
<p class="text-xl font-bold text-amber-500">{{ result.unusedKeys }}</p>
|
|
66
|
+
<p class="text-xs text-gray-400">{{ t('scan.unused', 'unused') }}</p>
|
|
67
|
+
</div>
|
|
68
|
+
<div v-if="result.scannedFiles !== undefined" class="bg-white dark:bg-gray-900 rounded-lg p-2.5 text-center">
|
|
69
|
+
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ result.scannedFiles }}</p>
|
|
70
|
+
<p class="text-xs text-gray-400">{{ t('scan.files_scanned', 'files scanned') }}</p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<p v-if="result.errors?.length" class="text-xs text-red-500">
|
|
74
|
+
{{ result.errors.length }} {{ t('scan.errors', 'errors') }}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
|
|
82
|
+
<template #footer>
|
|
83
|
+
<div class="flex justify-end gap-2">
|
|
84
|
+
<UButton color="neutral" variant="ghost" @click="open = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
85
|
+
<UButton
|
|
86
|
+
:loading="loading"
|
|
87
|
+
:disabled="mode === 'local' ? !localPath : !remoteUrl"
|
|
88
|
+
icon="i-heroicons-magnifying-glass"
|
|
89
|
+
@click="runScan"
|
|
90
|
+
>
|
|
91
|
+
{{ t('scan.run', 'Scan') }}
|
|
92
|
+
</UButton>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
95
|
+
</UModal>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup lang="ts">
|
|
99
|
+
const { t } = useT()
|
|
100
|
+
|
|
101
|
+
const props = defineProps<{
|
|
102
|
+
projectId: number
|
|
103
|
+
project?: { languages?: { code: string; name: string }[]; root_path?: string; source_url?: string }
|
|
104
|
+
}>()
|
|
105
|
+
|
|
106
|
+
const emit = defineEmits<{ done: [] }>()
|
|
107
|
+
|
|
108
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
109
|
+
|
|
110
|
+
const mode = ref<'local' | 'url'>('local')
|
|
111
|
+
const localPath = ref(props.project?.root_path ?? '')
|
|
112
|
+
const remoteUrl = ref(props.project?.source_url ?? '')
|
|
113
|
+
const loading = ref(false)
|
|
114
|
+
const result = ref<any>(null)
|
|
115
|
+
const error = ref('')
|
|
116
|
+
|
|
117
|
+
const modes = computed(() => [
|
|
118
|
+
{ value: 'local', label: t('scan.mode_local', 'Local'), icon: 'i-heroicons-computer-desktop' },
|
|
119
|
+
{ value: 'url', label: t('scan.mode_url', 'Via URL'), icon: 'i-heroicons-globe-alt' },
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
watch(open, (val) => {
|
|
123
|
+
if (val) {
|
|
124
|
+
result.value = null
|
|
125
|
+
error.value = ''
|
|
126
|
+
localPath.value = props.project?.root_path ?? ''
|
|
127
|
+
remoteUrl.value = props.project?.source_url ?? ''
|
|
128
|
+
mode.value = props.project?.root_path ? 'local' : props.project?.source_url ? 'url' : 'local'
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
async function runScan() {
|
|
133
|
+
loading.value = true
|
|
134
|
+
error.value = ''
|
|
135
|
+
result.value = null
|
|
136
|
+
try {
|
|
137
|
+
result.value = await $fetch('/api/scan', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
body: {
|
|
140
|
+
project_id: props.projectId,
|
|
141
|
+
mode: mode.value,
|
|
142
|
+
root_path: mode.value === 'local' ? localPath.value : undefined,
|
|
143
|
+
url: mode.value === 'url' ? remoteUrl.value : undefined,
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
emit('done')
|
|
147
|
+
} catch (e: any) {
|
|
148
|
+
error.value = e?.data?.message ?? t('common.error', 'Error')
|
|
149
|
+
} finally {
|
|
150
|
+
loading.value = false
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
</script>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<UModal :open="true" title="Translation History" @update:open="$emit('close')">
|
|
3
|
+
<template #body>
|
|
4
|
+
<div v-if="pending" class="space-y-3">
|
|
5
|
+
<USkeleton v-for="i in 3" :key="i" class="h-16" />
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div v-else-if="!history?.length" class="text-center py-8">
|
|
9
|
+
<UIcon name="i-heroicons-clock" class="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
|
10
|
+
<p class="text-gray-400">No history available</p>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div v-else class="space-y-3 max-h-96 overflow-y-auto">
|
|
14
|
+
<div
|
|
15
|
+
v-for="entry in history"
|
|
16
|
+
:key="entry.id"
|
|
17
|
+
class="border border-gray-200 dark:border-gray-700 rounded-lg p-3"
|
|
18
|
+
>
|
|
19
|
+
<div class="flex items-center justify-between mb-2">
|
|
20
|
+
<UBadge
|
|
21
|
+
:color="entry.changed_by === 'google-translate' ? 'info' : entry.changed_by === 'sync' ? 'warning' : 'success'"
|
|
22
|
+
size="xs"
|
|
23
|
+
>
|
|
24
|
+
{{ entry.changed_by === 'google-translate' ? 'Google Translate' : entry.changed_by === 'sync' ? 'File Sync' : 'Manual' }}
|
|
25
|
+
</UBadge>
|
|
26
|
+
<span class="text-xs text-gray-400">{{ formatDate(entry.changed_at) }}</span>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="grid grid-cols-2 gap-2">
|
|
30
|
+
<div>
|
|
31
|
+
<p class="text-xs text-gray-400 mb-1">Before</p>
|
|
32
|
+
<p class="text-sm text-gray-600 dark:text-gray-400 bg-red-50 dark:bg-red-900/20 rounded px-2 py-1 min-h-8">
|
|
33
|
+
{{ entry.old_value || '—' }}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
<div>
|
|
37
|
+
<p class="text-xs text-gray-400 mb-1">After</p>
|
|
38
|
+
<p class="text-sm text-gray-800 dark:text-gray-200 bg-green-50 dark:bg-green-900/20 rounded px-2 py-1 min-h-8">
|
|
39
|
+
{{ entry.new_value || '—' }}
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
<template #footer>
|
|
47
|
+
<UButton color="neutral" variant="outline" @click="$emit('close')">Close</UButton>
|
|
48
|
+
</template>
|
|
49
|
+
</UModal>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup lang="ts">
|
|
53
|
+
const props = defineProps<{
|
|
54
|
+
translationId: number | null
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
defineEmits<{ close: [] }>()
|
|
58
|
+
|
|
59
|
+
const { data: history, pending } = await useFetch(
|
|
60
|
+
() => props.translationId ? `/api/history/${props.translationId}` : null,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
function formatDate(date: string) {
|
|
64
|
+
return new Date(date).toLocaleString()
|
|
65
|
+
}
|
|
66
|
+
</script>
|