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,250 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="p-6 max-w-5xl mx-auto space-y-6">
|
|
3
|
+
<div class="flex items-center justify-between">
|
|
4
|
+
<div>
|
|
5
|
+
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('formats.number_title', 'Number formats') }}</h1>
|
|
6
|
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
7
|
+
{{ t('formats.number_subtitle', 'Configure') }} <code class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">$n(value, 'currency')</code> {{ t('formats.per_locale', 'by locale.') }}
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
<UButton icon="i-heroicons-plus" @click="openCreate">{{ t('formats.add_number_format', 'Add a format') }}</UButton>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<!-- Info box -->
|
|
14
|
+
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg px-4 py-3 text-sm text-blue-700 dark:text-blue-300 flex gap-2">
|
|
15
|
+
<UIcon name="i-heroicons-information-circle" class="shrink-0 mt-0.5" />
|
|
16
|
+
<div>
|
|
17
|
+
{{ t('formats.number_info', 'Use') }} <code class="font-mono text-xs bg-blue-100 dark:bg-blue-900/40 px-1 rounded">$n(1234.56, 'currency')</code> {{ t('formats.number_info2', 'in your templates. Options correspond to') }} <strong>Intl.NumberFormat</strong> {{ t('formats.parameters', 'parameters.') }}
|
|
18
|
+
<NuxtLink to="https://vue-i18n.intlify.dev/guide/essentials/number.html" target="_blank" class="underline ml-1">{{ t('formats.documentation', 'Documentation vue-i18n') }} ↗</NuxtLink>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<!-- Empty -->
|
|
23
|
+
<div v-if="!numberFormats?.length" class="text-center py-16 text-gray-400">
|
|
24
|
+
<UIcon name="i-heroicons-calculator" class="text-4xl mb-3" />
|
|
25
|
+
<p class="font-medium">{{ t('formats.no_format', 'No format configured') }}</p>
|
|
26
|
+
<p class="text-sm mt-1">{{ t('formats.add_to_use', 'Add formats to use') }} <code class="font-mono text-xs">$n()</code> {{ t('formats.in_templates', 'in your templates.') }}</p>
|
|
27
|
+
<UButton class="mt-4" icon="i-heroicons-plus" @click="openCreate">{{ t('formats.add_number_format', 'Add a format') }}</UButton>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- Grouped by locale -->
|
|
31
|
+
<div v-else class="space-y-6">
|
|
32
|
+
<div v-for="(formats, locale) in groupedByLocale" :key="locale">
|
|
33
|
+
<div class="flex items-center gap-2 mb-3">
|
|
34
|
+
<span class="text-xs font-mono font-bold bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-gray-500 dark:text-gray-400 uppercase">{{ locale }}</span>
|
|
35
|
+
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ findLanguage(locale)?.nativeName || locale }}</span>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
38
|
+
<UCard v-for="fmt in formats" :key="fmt.id">
|
|
39
|
+
<div class="flex items-start justify-between gap-2">
|
|
40
|
+
<div class="min-w-0 flex-1">
|
|
41
|
+
<div class="flex items-center gap-2 mb-1">
|
|
42
|
+
<code class="text-sm font-mono font-semibold text-primary-600 dark:text-primary-400">'{{ fmt.name }}'</code>
|
|
43
|
+
<UBadge size="xs" color="neutral" variant="soft">{{ fmt.options.style || 'decimal' }}</UBadge>
|
|
44
|
+
<UBadge v-if="fmt.options.currency" size="xs" color="info" variant="soft">{{ fmt.options.currency }}</UBadge>
|
|
45
|
+
</div>
|
|
46
|
+
<!-- Live preview -->
|
|
47
|
+
<p class="text-xs text-gray-400 font-mono">
|
|
48
|
+
{{ previewNumber(fmt.options) }}
|
|
49
|
+
</p>
|
|
50
|
+
<!-- Options summary -->
|
|
51
|
+
<div class="mt-2 flex flex-wrap gap-1">
|
|
52
|
+
<span v-for="(v, k) in fmt.options" :key="k" class="text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 font-mono text-gray-500 dark:text-gray-400">
|
|
53
|
+
{{ k }}: {{ v }}
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="flex gap-1 shrink-0">
|
|
58
|
+
<UButton icon="i-heroicons-pencil" size="xs" color="neutral" variant="ghost" @click="openEdit(fmt)" />
|
|
59
|
+
<UButton icon="i-heroicons-trash" size="xs" color="error" variant="ghost" @click="remove(fmt.id)" />
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</UCard>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<!-- Create/Edit modal -->
|
|
68
|
+
<UModal v-model:open="showModal" :title="editing ? t('formats.edit_format', 'Edit format') : t('formats.new_number_format', 'New number format')">
|
|
69
|
+
<template #body>
|
|
70
|
+
<div class="space-y-4">
|
|
71
|
+
<div class="grid grid-cols-2 gap-3">
|
|
72
|
+
<UFormField :label="t('formats.locale', 'Locale')" required>
|
|
73
|
+
<USelect v-model="form.locale" :items="localeOptions" class="w-full" />
|
|
74
|
+
</UFormField>
|
|
75
|
+
<UFormField :label="t('formats.format_name', 'Format name')" :hint="t('formats.format_name_hint', 'E.g.: currency, decimal, percent')" required>
|
|
76
|
+
<UInput v-model="form.name" placeholder="currency" class="w-full" />
|
|
77
|
+
</UFormField>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<UFormField :label="t('formats.style', 'Style')">
|
|
81
|
+
<div class="grid grid-cols-4 gap-1.5">
|
|
82
|
+
<button
|
|
83
|
+
v-for="s in ['decimal', 'currency', 'percent', 'unit']"
|
|
84
|
+
:key="s"
|
|
85
|
+
class="px-2 py-1.5 rounded border text-xs font-mono transition-colors"
|
|
86
|
+
:class="form.options.style === s
|
|
87
|
+
? 'bg-primary-50 dark:bg-primary-900/30 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-300'
|
|
88
|
+
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300'"
|
|
89
|
+
@click="form.options.style = s"
|
|
90
|
+
>{{ s }}</button>
|
|
91
|
+
</div>
|
|
92
|
+
</UFormField>
|
|
93
|
+
|
|
94
|
+
<!-- Currency options -->
|
|
95
|
+
<div v-if="form.options.style === 'currency'" class="grid grid-cols-2 gap-3">
|
|
96
|
+
<UFormField :label="t('formats.currency', 'Currency (ISO 4217)')">
|
|
97
|
+
<UInput v-model="form.options.currency" placeholder="EUR" class="w-full" />
|
|
98
|
+
</UFormField>
|
|
99
|
+
<UFormField :label="t('formats.currency_display', 'Currency display')">
|
|
100
|
+
<USelect v-model="form.options.currencyDisplay" :items="['symbol', 'narrowSymbol', 'code', 'name']" class="w-full" />
|
|
101
|
+
</UFormField>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Unit options -->
|
|
105
|
+
<div v-if="form.options.style === 'unit'" class="grid grid-cols-2 gap-3">
|
|
106
|
+
<UFormField :label="t('formats.unit', 'Unit')" :hint="t('formats.unit_hint', 'E.g.: kilometer, kilogram')">
|
|
107
|
+
<UInput v-model="form.options.unit" placeholder="kilometer" class="w-full" />
|
|
108
|
+
</UFormField>
|
|
109
|
+
<UFormField :label="t('formats.unit_display', 'Unit display')">
|
|
110
|
+
<USelect v-model="form.options.unitDisplay" :items="['short', 'long', 'narrow']" class="w-full" />
|
|
111
|
+
</UFormField>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div class="grid grid-cols-2 gap-3">
|
|
115
|
+
<UFormField :label="t('formats.notation', 'Notation')">
|
|
116
|
+
<USelect v-model="form.options.notation" :items="[{label: t('formats.standard_default', 'Standard (default)'), value: ''}, 'standard', 'scientific', 'engineering', 'compact']" class="w-full" />
|
|
117
|
+
</UFormField>
|
|
118
|
+
<UFormField :label="t('formats.grouping', 'Grouping')">
|
|
119
|
+
<USelect v-model="form.options.useGrouping" :items="[{label: t('formats.auto_default', 'Auto (default)'), value: ''}, {label: t('formats.enabled', 'Enabled'), value: true}, {label: t('formats.disabled', 'Disabled'), value: false}]" class="w-full" />
|
|
120
|
+
</UFormField>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div class="grid grid-cols-2 gap-3">
|
|
124
|
+
<UFormField :label="t('formats.min_fraction_digits', 'Min digits after decimal')">
|
|
125
|
+
<UInput v-model.number="form.options.minimumFractionDigits" type="number" min="0" max="20" placeholder="0" class="w-full" />
|
|
126
|
+
</UFormField>
|
|
127
|
+
<UFormField :label="t('formats.max_fraction_digits', 'Max digits after decimal')">
|
|
128
|
+
<UInput v-model.number="form.options.maximumFractionDigits" type="number" min="0" max="20" placeholder="3" class="w-full" />
|
|
129
|
+
</UFormField>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Live preview -->
|
|
133
|
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
|
134
|
+
<p class="text-xs text-gray-400 mb-1">{{ t('formats.preview', 'Preview') }} (1234.56):</p>
|
|
135
|
+
<p class="text-sm font-mono font-semibold text-gray-700 dark:text-gray-300">{{ previewNumber(cleanOptions) }}</p>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</template>
|
|
139
|
+
<template #footer>
|
|
140
|
+
<div class="flex justify-end gap-2">
|
|
141
|
+
<UButton color="neutral" variant="ghost" @click="showModal = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
142
|
+
<UButton :loading="saving" @click="save">{{ editing ? t('formats.update', 'Update') : t('common.create', 'Create') }}</UButton>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
</UModal>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|
|
148
|
+
|
|
149
|
+
<script setup lang="ts">
|
|
150
|
+
const { currentProject } = useProject()
|
|
151
|
+
const { numberFormats, createNumberFormat, updateNumberFormat, deleteNumberFormat } = useFormats()
|
|
152
|
+
const { findLanguage, projectLanguages } = useLanguages()
|
|
153
|
+
const toast = useToast()
|
|
154
|
+
const { t } = useT()
|
|
155
|
+
|
|
156
|
+
const localeOptions = computed(() =>
|
|
157
|
+
(projectLanguages.value || []).map((l: any) => ({ label: `${l.code} — ${l.name}`, value: l.code }))
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const groupedByLocale = computed(() => {
|
|
161
|
+
const groups: Record<string, any[]> = {}
|
|
162
|
+
for (const fmt of (numberFormats.value || [])) {
|
|
163
|
+
if (!groups[fmt.locale]) groups[fmt.locale] = []
|
|
164
|
+
groups[fmt.locale].push(fmt)
|
|
165
|
+
}
|
|
166
|
+
return groups
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
function previewNumber(opts: Record<string, any>) {
|
|
170
|
+
try {
|
|
171
|
+
const clean: any = {}
|
|
172
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
173
|
+
if (v !== '' && v !== null && v !== undefined) clean[k] = v
|
|
174
|
+
}
|
|
175
|
+
return new Intl.NumberFormat('fr-FR', clean).format(1234.56)
|
|
176
|
+
} catch {
|
|
177
|
+
return '—'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const showModal = ref(false)
|
|
182
|
+
const saving = ref(false)
|
|
183
|
+
const editing = ref<any>(null)
|
|
184
|
+
|
|
185
|
+
const defaultForm = () => ({
|
|
186
|
+
locale: (projectLanguages.value as any[])?.[0]?.code || 'fr',
|
|
187
|
+
name: '',
|
|
188
|
+
options: {
|
|
189
|
+
style: 'decimal',
|
|
190
|
+
currency: '',
|
|
191
|
+
currencyDisplay: 'symbol',
|
|
192
|
+
notation: '',
|
|
193
|
+
useGrouping: '',
|
|
194
|
+
minimumFractionDigits: '',
|
|
195
|
+
maximumFractionDigits: '',
|
|
196
|
+
unit: '',
|
|
197
|
+
unitDisplay: 'short',
|
|
198
|
+
} as Record<string, any>,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const form = ref(defaultForm())
|
|
202
|
+
|
|
203
|
+
const cleanOptions = computed(() => {
|
|
204
|
+
const o: Record<string, any> = {}
|
|
205
|
+
for (const [k, v] of Object.entries(form.value.options)) {
|
|
206
|
+
if (v !== '' && v !== null && v !== undefined) o[k] = v
|
|
207
|
+
}
|
|
208
|
+
return o
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
function openCreate() {
|
|
212
|
+
editing.value = null
|
|
213
|
+
form.value = defaultForm()
|
|
214
|
+
showModal.value = true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function openEdit(fmt: any) {
|
|
218
|
+
editing.value = fmt
|
|
219
|
+
form.value = {
|
|
220
|
+
locale: fmt.locale,
|
|
221
|
+
name: fmt.name,
|
|
222
|
+
options: { style: 'decimal', currency: '', currencyDisplay: 'symbol', notation: '', useGrouping: '', minimumFractionDigits: '', maximumFractionDigits: '', unit: '', unitDisplay: 'short', ...fmt.options },
|
|
223
|
+
}
|
|
224
|
+
showModal.value = true
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function save() {
|
|
228
|
+
if (!form.value.locale || !form.value.name) return
|
|
229
|
+
saving.value = true
|
|
230
|
+
try {
|
|
231
|
+
if (editing.value) {
|
|
232
|
+
await updateNumberFormat(editing.value.id, form.value.locale, form.value.name, cleanOptions.value)
|
|
233
|
+
toast.add({ title: t('formats.format_updated', 'Format updated'), color: 'success' })
|
|
234
|
+
} else {
|
|
235
|
+
await createNumberFormat(form.value.locale, form.value.name, cleanOptions.value)
|
|
236
|
+
toast.add({ title: t('formats.format_created', 'Format created'), color: 'success' })
|
|
237
|
+
}
|
|
238
|
+
showModal.value = false
|
|
239
|
+
} catch (e: any) {
|
|
240
|
+
toast.add({ title: t('common.error', 'Error'), description: e.message, color: 'error' })
|
|
241
|
+
} finally {
|
|
242
|
+
saving.value = false
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function remove(id: number) {
|
|
247
|
+
await deleteNumberFormat(id)
|
|
248
|
+
toast.add({ title: t('formats.format_deleted', 'Format deleted'), color: 'success' })
|
|
249
|
+
}
|
|
250
|
+
</script>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="p-6 space-y-6">
|
|
3
|
+
<div class="flex items-center justify-between">
|
|
4
|
+
<div>
|
|
5
|
+
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('dashboard.title', 'Dashboard') }}</h1>
|
|
6
|
+
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">
|
|
7
|
+
{{ currentProject?.name }} · {{ currentProject?.root_path }}
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- Stats row -->
|
|
13
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
14
|
+
<UCard v-for="stat in topStats" :key="stat.label">
|
|
15
|
+
<div class="flex items-center gap-3">
|
|
16
|
+
<div class="p-2 rounded-lg" :class="stat.bg">
|
|
17
|
+
<UIcon :name="stat.icon" class="text-xl" :class="stat.color" />
|
|
18
|
+
</div>
|
|
19
|
+
<div>
|
|
20
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">{{ stat.label }}</p>
|
|
21
|
+
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
22
|
+
<span v-if="pending">—</span>
|
|
23
|
+
<span v-else>{{ stat.value }}</span>
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</UCard>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
31
|
+
<!-- Coverage -->
|
|
32
|
+
<UCard class="lg:col-span-2">
|
|
33
|
+
<template #header>
|
|
34
|
+
<div class="flex items-center justify-between">
|
|
35
|
+
<h2 class="font-semibold text-gray-900 dark:text-white">{{ t('dashboard.coverage_by_language', 'Coverage by language') }}</h2>
|
|
36
|
+
<UButton :to="`/projects/${projectId}/translations`" variant="ghost" size="xs" trailing-icon="i-heroicons-arrow-right" color="neutral">{{ t('dashboard.see_all', 'See all') }}</UButton>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
<div v-if="pending" class="space-y-4"><USkeleton v-for="i in 3" :key="i" class="h-14" /></div>
|
|
40
|
+
<div v-else-if="!stats?.languages?.length" class="text-center py-10">
|
|
41
|
+
<UIcon name="i-heroicons-flag" class="text-4xl text-gray-300 mb-2" />
|
|
42
|
+
<p class="text-gray-400 text-sm">{{ t('languages.none', 'No language configured') }}</p>
|
|
43
|
+
<UButton :to="`/projects/${projectId}/languages`" size="sm" class="mt-3">{{ t('languages.add', 'Add a language') }}</UButton>
|
|
44
|
+
</div>
|
|
45
|
+
<div v-else class="space-y-4">
|
|
46
|
+
<div v-for="lang in stats.languages" :key="lang.code">
|
|
47
|
+
<div class="flex items-center justify-between mb-1.5">
|
|
48
|
+
<div class="flex items-center gap-2">
|
|
49
|
+
<span class="font-medium text-sm text-gray-800 dark:text-gray-200">{{ findLanguage(lang.code)?.nativeName || lang.name }}</span>
|
|
50
|
+
<UBadge size="xs" variant="outline" color="neutral">{{ lang.code }}</UBadge>
|
|
51
|
+
<UBadge v-if="lang.is_default" size="xs" color="primary">{{ t('languages.default_badge', 'Default') }}</UBadge>
|
|
52
|
+
</div>
|
|
53
|
+
<span class="text-sm font-semibold" :class="lang.coverage >= 80 ? 'text-green-600' : lang.coverage >= 50 ? 'text-yellow-600' : 'text-red-500'">
|
|
54
|
+
{{ lang.coverage }}%
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="h-2.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden flex">
|
|
58
|
+
<div class="bg-green-500 transition-all duration-500" :style="{ width: `${pct(lang.approved, lang.total)}%` }" />
|
|
59
|
+
<div class="bg-blue-400 transition-all duration-500" :style="{ width: `${pct(lang.reviewed, lang.total)}%` }" />
|
|
60
|
+
<div class="bg-yellow-400 transition-all duration-500" :style="{ width: `${pct(lang.draft, lang.total)}%` }" />
|
|
61
|
+
</div>
|
|
62
|
+
<div class="flex gap-4 mt-1 text-xs text-gray-400">
|
|
63
|
+
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />{{ t('status.approved', 'Approved') }} ({{ lang.approved }})</span>
|
|
64
|
+
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-blue-400 inline-block" />{{ t('status.reviewed', 'Reviewed') }} ({{ lang.reviewed }})</span>
|
|
65
|
+
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-yellow-400 inline-block" />{{ t('status.draft', 'Draft') }} ({{ lang.draft }})</span>
|
|
66
|
+
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 inline-block" />{{ t('status.missing', 'Missing') }} ({{ lang.missing }})</span>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</UCard>
|
|
71
|
+
|
|
72
|
+
<!-- Activity -->
|
|
73
|
+
<UCard>
|
|
74
|
+
<template #header><h2 class="font-semibold text-gray-900 dark:text-white">{{ t('dashboard.recent_activity', 'Recent activity') }}</h2></template>
|
|
75
|
+
<div v-if="pending" class="space-y-3"><USkeleton v-for="i in 5" :key="i" class="h-10" /></div>
|
|
76
|
+
<div v-else-if="!stats?.recentActivity?.length" class="text-center py-8">
|
|
77
|
+
<UIcon name="i-heroicons-clock" class="text-4xl text-gray-300 mb-2" />
|
|
78
|
+
<p class="text-gray-400 text-sm">{{ t('dashboard.no_activity', 'No recent activity') }}</p>
|
|
79
|
+
</div>
|
|
80
|
+
<div v-else class="space-y-1 overflow-y-auto max-h-72">
|
|
81
|
+
<div v-for="activity in stats.recentActivity" :key="activity.id" class="flex gap-2 items-start p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
82
|
+
<UIcon
|
|
83
|
+
:name="activity.changed_by === 'google-translate' ? 'i-heroicons-sparkles' : activity.changed_by === 'sync' ? 'i-heroicons-arrow-path' : 'i-heroicons-pencil'"
|
|
84
|
+
class="text-sm mt-0.5 shrink-0"
|
|
85
|
+
:class="activity.changed_by === 'google-translate' ? 'text-yellow-500' : activity.changed_by === 'sync' ? 'text-blue-500' : 'text-gray-400'"
|
|
86
|
+
/>
|
|
87
|
+
<div class="min-w-0 flex-1">
|
|
88
|
+
<p class="text-xs font-mono font-medium text-gray-700 dark:text-gray-300 truncate">{{ activity.key }}</p>
|
|
89
|
+
<p class="text-xs text-gray-400">{{ activity.language_code }} · {{ formatRelative(activity.changed_at) }}</p>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</UCard>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!-- Code snippet card (shown when advanced features are enabled) -->
|
|
97
|
+
<UCard v-if="currentProject && (currentProject.enable_number_formats || currentProject.enable_datetime_formats || currentProject.enable_modifiers)">
|
|
98
|
+
<template #header>
|
|
99
|
+
<div class="flex items-center justify-between">
|
|
100
|
+
<div class="flex items-center gap-2">
|
|
101
|
+
<UIcon name="i-heroicons-code-bracket-square" class="text-purple-500" />
|
|
102
|
+
<h2 class="font-semibold text-gray-900 dark:text-white">{{ t('dashboard.generated_config', 'Generated vue-i18n configuration') }}</h2>
|
|
103
|
+
</div>
|
|
104
|
+
<UButton
|
|
105
|
+
color="neutral"
|
|
106
|
+
variant="ghost"
|
|
107
|
+
size="xs"
|
|
108
|
+
icon="i-heroicons-clipboard"
|
|
109
|
+
@click="copySnippet"
|
|
110
|
+
>{{ t('dashboard.copy', 'Copy') }}</UButton>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
113
|
+
<div v-if="snippetPending" class="space-y-2">
|
|
114
|
+
<USkeleton class="h-4 w-full" />
|
|
115
|
+
<USkeleton class="h-4 w-3/4" />
|
|
116
|
+
<USkeleton class="h-4 w-5/6" />
|
|
117
|
+
</div>
|
|
118
|
+
<div v-else-if="snippetData?.snippet">
|
|
119
|
+
<pre class="text-xs font-mono text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 overflow-auto max-h-64">{{ snippetData.snippet }}</pre>
|
|
120
|
+
</div>
|
|
121
|
+
<div v-else class="text-sm text-gray-400 italic">{{ t('dashboard.no_config_generated', 'No configuration generated for this project.') }}</div>
|
|
122
|
+
</UCard>
|
|
123
|
+
|
|
124
|
+
<!-- Quick actions -->
|
|
125
|
+
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
126
|
+
<UButton block variant="outline" color="neutral" icon="i-heroicons-plus-circle" :to="`/projects/${projectId}/translations`">{{ t('dashboard.new_key', 'New key') }}</UButton>
|
|
127
|
+
<UButton block variant="outline" color="neutral" icon="i-heroicons-flag" :to="`/projects/${projectId}/languages`">{{ t('nav.languages', 'Languages') }}</UButton>
|
|
128
|
+
<UButton block variant="outline" color="neutral" icon="i-heroicons-exclamation-triangle" :to="`/projects/${projectId}/translations?status=unused`">
|
|
129
|
+
{{ t('nav.unused', 'Unused') }}
|
|
130
|
+
<UBadge v-if="stats?.unusedKeys" size="xs" color="warning" class="ml-1">{{ stats.unusedKeys }}</UBadge>
|
|
131
|
+
</UButton>
|
|
132
|
+
<UButton block variant="outline" color="neutral" icon="i-heroicons-rectangle-stack" to="/projects">{{ t('nav.projects', 'Projects') }}</UButton>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
136
|
+
|
|
137
|
+
<script setup lang="ts">
|
|
138
|
+
const route = useRoute()
|
|
139
|
+
const projectId = computed(() => route.params.id)
|
|
140
|
+
const { currentProject } = useProject()
|
|
141
|
+
const { findLanguage } = useLanguages()
|
|
142
|
+
const { stats, pending } = useStats()
|
|
143
|
+
const toast = useToast()
|
|
144
|
+
const { t } = useT()
|
|
145
|
+
|
|
146
|
+
const { data: snippetData, pending: snippetPending } = useFetch<any>('/api/formats/snippet', {
|
|
147
|
+
query: computed(() => ({ project_id: projectId.value })),
|
|
148
|
+
default: () => null,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
async function copySnippet() {
|
|
152
|
+
if (!snippetData.value?.snippet) return
|
|
153
|
+
await navigator.clipboard.writeText(snippetData.value.snippet)
|
|
154
|
+
toast.add({ title: t('common.copied', 'Copied!'), color: 'success' })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const topStats = computed(() => {
|
|
158
|
+
const s = stats.value as any
|
|
159
|
+
if (!s) return []
|
|
160
|
+
const totalT = (s.languages as any[]).reduce((sum: number, l: any) => sum + l.translated, 0)
|
|
161
|
+
const totalP = (s.languages as any[]).reduce((sum: number, l: any) => sum + l.total, 0)
|
|
162
|
+
const cov = totalP > 0 ? Math.round((totalT / totalP) * 100) : 0
|
|
163
|
+
return [
|
|
164
|
+
{ label: t('dashboard.total_keys', 'total keys'), value: s.totalKeys, icon: 'i-heroicons-key', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
|
165
|
+
{ label: t('dashboard.languages', 'Languages'), value: s.languages.length, icon: 'i-heroicons-flag', color: 'text-purple-600', bg: 'bg-purple-50 dark:bg-purple-900/20' },
|
|
166
|
+
{ label: t('dashboard.coverage', 'coverage'), value: `${cov}%`, icon: 'i-heroicons-check-badge', color: 'text-green-600', bg: 'bg-green-50 dark:bg-green-900/20' },
|
|
167
|
+
{ label: t('dashboard.unused_keys', 'unused keys'), value: s.unusedKeys, icon: 'i-heroicons-exclamation-triangle', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-900/20' },
|
|
168
|
+
]
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
function pct(v: number, t: number) { return t > 0 ? Math.min(100, Math.round((v / t) * 100)) : 0 }
|
|
172
|
+
|
|
173
|
+
function formatRelative(date: string) {
|
|
174
|
+
const diff = Date.now() - new Date(date).getTime()
|
|
175
|
+
const min = Math.floor(diff / 60000)
|
|
176
|
+
if (min < 1) return t('common.just_now', 'just now')
|
|
177
|
+
if (min < 60) return `${t('common.ago', 'ago')} ${min}min`
|
|
178
|
+
const h = Math.floor(min / 60)
|
|
179
|
+
if (h < 24) return `${t('common.ago', 'ago')} ${h}h`
|
|
180
|
+
return `${t('common.ago', 'ago')} ${Math.floor(h / 24)}d`
|
|
181
|
+
}
|
|
182
|
+
</script>
|