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,240 @@
|
|
|
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.datetime_title', 'Date formats') }}</h1>
|
|
6
|
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
7
|
+
{{ t('formats.datetime_subtitle', 'Configure') }} <code class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">$d(date, 'short')</code> {{ t('formats.per_locale', 'by locale.') }}
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
<UButton icon="i-heroicons-plus" @click="openCreate">{{ t('formats.add_datetime_format', 'Add a format') }}</UButton>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<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">
|
|
14
|
+
<UIcon name="i-heroicons-information-circle" class="shrink-0 mt-0.5" />
|
|
15
|
+
<div>
|
|
16
|
+
{{ t('formats.datetime_info', 'Use') }} <code class="font-mono text-xs bg-blue-100 dark:bg-blue-900/40 px-1 rounded">$d(new Date(), 'short')</code> {{ t('formats.datetime_info2', 'in your templates. Options correspond to') }} <strong>Intl.DateTimeFormat</strong> {{ t('formats.parameters', 'parameters.') }}
|
|
17
|
+
<NuxtLink to="https://vue-i18n.intlify.dev/guide/essentials/datetime.html" target="_blank" class="underline ml-1">{{ t('formats.documentation', 'Documentation vue-i18n') }} ↗</NuxtLink>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div v-if="!datetimeFormats?.length" class="text-center py-16 text-gray-400">
|
|
22
|
+
<UIcon name="i-heroicons-calendar" class="text-4xl mb-3" />
|
|
23
|
+
<p class="font-medium">{{ t('formats.no_format', 'No format configured') }}</p>
|
|
24
|
+
<p class="text-sm mt-1">{{ t('formats.add_to_use', 'Add formats to use') }} <code class="font-mono text-xs">$d()</code> {{ t('formats.in_templates', 'in your templates.') }}</p>
|
|
25
|
+
<UButton class="mt-4" icon="i-heroicons-plus" @click="openCreate">{{ t('formats.add_datetime_format', 'Add a format') }}</UButton>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div v-else class="space-y-6">
|
|
29
|
+
<div v-for="(formats, locale) in groupedByLocale" :key="locale">
|
|
30
|
+
<div class="flex items-center gap-2 mb-3">
|
|
31
|
+
<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>
|
|
32
|
+
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ findLanguage(locale)?.nativeName || locale }}</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
35
|
+
<UCard v-for="fmt in formats" :key="fmt.id">
|
|
36
|
+
<div class="flex items-start justify-between gap-2">
|
|
37
|
+
<div class="min-w-0 flex-1">
|
|
38
|
+
<div class="flex items-center gap-2 mb-1">
|
|
39
|
+
<code class="text-sm font-mono font-semibold text-primary-600 dark:text-primary-400">'{{ fmt.name }}'</code>
|
|
40
|
+
</div>
|
|
41
|
+
<p class="text-xs text-gray-400 font-mono">{{ previewDate(fmt.options, fmt.locale) }}</p>
|
|
42
|
+
<div class="mt-2 flex flex-wrap gap-1">
|
|
43
|
+
<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">
|
|
44
|
+
{{ k }}: {{ v }}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="flex gap-1 shrink-0">
|
|
49
|
+
<UButton icon="i-heroicons-pencil" size="xs" color="neutral" variant="ghost" @click="openEdit(fmt)" />
|
|
50
|
+
<UButton icon="i-heroicons-trash" size="xs" color="error" variant="ghost" @click="remove(fmt.id)" />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</UCard>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<UModal v-model:open="showModal" :title="editing ? t('formats.edit_format', 'Edit format') : t('formats.new_datetime_format', 'New date format')">
|
|
59
|
+
<template #body>
|
|
60
|
+
<div class="space-y-4">
|
|
61
|
+
<div class="grid grid-cols-2 gap-3">
|
|
62
|
+
<UFormField :label="t('formats.locale', 'Locale')" required>
|
|
63
|
+
<USelect v-model="form.locale" :items="localeOptions" class="w-full" />
|
|
64
|
+
</UFormField>
|
|
65
|
+
<UFormField :label="t('formats.format_name', 'Format name')" :hint="t('formats.datetime_format_name_hint', 'E.g.: short, long, medium')" required>
|
|
66
|
+
<UInput v-model="form.name" placeholder="short" class="w-full" />
|
|
67
|
+
</UFormField>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- Shortcut preset styles -->
|
|
71
|
+
<div class="grid grid-cols-2 gap-3">
|
|
72
|
+
<UFormField :label="t('formats.date_style', 'Date style (shortcut)')">
|
|
73
|
+
<USelect v-model="form.options.dateStyle" :items="[{label: t('formats.none_default', 'None (default)'), value: ''}, 'full', 'long', 'medium', 'short']" class="w-full" />
|
|
74
|
+
</UFormField>
|
|
75
|
+
<UFormField :label="t('formats.time_style', 'Time style (shortcut)')">
|
|
76
|
+
<USelect v-model="form.options.timeStyle" :items="[{label: t('formats.none_default', 'None (default)'), value: ''}, 'full', 'long', 'medium', 'short']" class="w-full" />
|
|
77
|
+
</UFormField>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<!-- When no shortcut, show individual options -->
|
|
81
|
+
<template v-if="!form.options.dateStyle && !form.options.timeStyle">
|
|
82
|
+
<div class="grid grid-cols-3 gap-3">
|
|
83
|
+
<UFormField :label="t('formats.year', 'Year')">
|
|
84
|
+
<USelect v-model="form.options.year" :items="[{label: '—', value: ''}, 'numeric', '2-digit']" class="w-full" />
|
|
85
|
+
</UFormField>
|
|
86
|
+
<UFormField :label="t('formats.month', 'Month')">
|
|
87
|
+
<USelect v-model="form.options.month" :items="[{label: '—', value: ''}, 'numeric', '2-digit', 'long', 'short', 'narrow']" class="w-full" />
|
|
88
|
+
</UFormField>
|
|
89
|
+
<UFormField :label="t('formats.day', 'Day')">
|
|
90
|
+
<USelect v-model="form.options.day" :items="[{label: '—', value: ''}, 'numeric', '2-digit']" class="w-full" />
|
|
91
|
+
</UFormField>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="grid grid-cols-3 gap-3">
|
|
94
|
+
<UFormField :label="t('formats.weekday', 'Weekday')">
|
|
95
|
+
<USelect v-model="form.options.weekday" :items="[{label: '—', value: ''}, 'long', 'short', 'narrow']" class="w-full" />
|
|
96
|
+
</UFormField>
|
|
97
|
+
<UFormField :label="t('formats.hour', 'Hour')">
|
|
98
|
+
<USelect v-model="form.options.hour" :items="[{label: '—', value: ''}, 'numeric', '2-digit']" class="w-full" />
|
|
99
|
+
</UFormField>
|
|
100
|
+
<UFormField :label="t('formats.minute', 'Minute')">
|
|
101
|
+
<USelect v-model="form.options.minute" :items="[{label: '—', value: ''}, 'numeric', '2-digit']" class="w-full" />
|
|
102
|
+
</UFormField>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="grid grid-cols-3 gap-3">
|
|
105
|
+
<UFormField :label="t('formats.second', 'Second')">
|
|
106
|
+
<USelect v-model="form.options.second" :items="[{label: '—', value: ''}, 'numeric', '2-digit']" class="w-full" />
|
|
107
|
+
</UFormField>
|
|
108
|
+
<UFormField :label="t('formats.hour12', '12h format')">
|
|
109
|
+
<USelect v-model="form.options.hour12" :items="[{label: t('formats.auto_default', 'Auto (default)'), value: ''}, {label: t('formats.yes', 'Yes'), value: true}, {label: t('formats.no', 'No'), value: false}]" class="w-full" />
|
|
110
|
+
</UFormField>
|
|
111
|
+
<UFormField :label="t('formats.timezone', 'Timezone')">
|
|
112
|
+
<UInput v-model="form.options.timeZone" placeholder="Europe/Paris" class="w-full" />
|
|
113
|
+
</UFormField>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
|
118
|
+
<p class="text-xs text-gray-400 mb-1">{{ t('formats.preview', 'Preview') }}:</p>
|
|
119
|
+
<p class="text-sm font-mono font-semibold text-gray-700 dark:text-gray-300">{{ previewDate(cleanOptions, form.locale) }}</p>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
123
|
+
<template #footer>
|
|
124
|
+
<div class="flex justify-end gap-2">
|
|
125
|
+
<UButton color="neutral" variant="ghost" @click="showModal = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
126
|
+
<UButton :loading="saving" @click="save">{{ editing ? t('formats.update', 'Update') : t('common.create', 'Create') }}</UButton>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|
|
129
|
+
</UModal>
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<script setup lang="ts">
|
|
134
|
+
const { currentProject } = useProject()
|
|
135
|
+
const { datetimeFormats, createDatetimeFormat, updateDatetimeFormat, deleteDatetimeFormat } = useFormats()
|
|
136
|
+
const { findLanguage, projectLanguages } = useLanguages()
|
|
137
|
+
const toast = useToast()
|
|
138
|
+
const { t } = useT()
|
|
139
|
+
|
|
140
|
+
const localeOptions = computed(() =>
|
|
141
|
+
(projectLanguages.value || []).map((l: any) => ({ label: `${l.code} — ${l.name}`, value: l.code }))
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const groupedByLocale = computed(() => {
|
|
145
|
+
const groups: Record<string, any[]> = {}
|
|
146
|
+
for (const fmt of (datetimeFormats.value || [])) {
|
|
147
|
+
if (!groups[fmt.locale]) groups[fmt.locale] = []
|
|
148
|
+
groups[fmt.locale].push(fmt)
|
|
149
|
+
}
|
|
150
|
+
return groups
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const PREVIEW_DATE = new Date(2024, 0, 15, 14, 30, 0)
|
|
154
|
+
|
|
155
|
+
function previewDate(opts: Record<string, any>, locale: string) {
|
|
156
|
+
try {
|
|
157
|
+
const clean: any = {}
|
|
158
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
159
|
+
if (v !== '' && v !== null && v !== undefined) clean[k] = v
|
|
160
|
+
}
|
|
161
|
+
return new Intl.DateTimeFormat(locale || 'fr-FR', clean).format(PREVIEW_DATE)
|
|
162
|
+
} catch {
|
|
163
|
+
return '—'
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const showModal = ref(false)
|
|
168
|
+
const saving = ref(false)
|
|
169
|
+
const editing = ref<any>(null)
|
|
170
|
+
|
|
171
|
+
const defaultOptions = () => ({
|
|
172
|
+
dateStyle: '',
|
|
173
|
+
timeStyle: '',
|
|
174
|
+
year: '',
|
|
175
|
+
month: '',
|
|
176
|
+
day: '',
|
|
177
|
+
weekday: '',
|
|
178
|
+
hour: '',
|
|
179
|
+
minute: '',
|
|
180
|
+
second: '',
|
|
181
|
+
hour12: '',
|
|
182
|
+
timeZone: '',
|
|
183
|
+
} as Record<string, any>)
|
|
184
|
+
|
|
185
|
+
const defaultForm = () => ({
|
|
186
|
+
locale: (projectLanguages.value as any[])?.[0]?.code || 'fr',
|
|
187
|
+
name: '',
|
|
188
|
+
options: defaultOptions(),
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const form = ref(defaultForm())
|
|
192
|
+
|
|
193
|
+
const cleanOptions = computed(() => {
|
|
194
|
+
const o: Record<string, any> = {}
|
|
195
|
+
for (const [k, v] of Object.entries(form.value.options)) {
|
|
196
|
+
if (v !== '' && v !== null && v !== undefined) o[k] = v
|
|
197
|
+
}
|
|
198
|
+
return o
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
function openCreate() {
|
|
202
|
+
editing.value = null
|
|
203
|
+
form.value = defaultForm()
|
|
204
|
+
showModal.value = true
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function openEdit(fmt: any) {
|
|
208
|
+
editing.value = fmt
|
|
209
|
+
form.value = {
|
|
210
|
+
locale: fmt.locale,
|
|
211
|
+
name: fmt.name,
|
|
212
|
+
options: { ...defaultOptions(), ...fmt.options },
|
|
213
|
+
}
|
|
214
|
+
showModal.value = true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function save() {
|
|
218
|
+
if (!form.value.locale || !form.value.name) return
|
|
219
|
+
saving.value = true
|
|
220
|
+
try {
|
|
221
|
+
if (editing.value) {
|
|
222
|
+
await updateDatetimeFormat(editing.value.id, form.value.locale, form.value.name, cleanOptions.value)
|
|
223
|
+
toast.add({ title: t('formats.format_updated', 'Format updated'), color: 'success' })
|
|
224
|
+
} else {
|
|
225
|
+
await createDatetimeFormat(form.value.locale, form.value.name, cleanOptions.value)
|
|
226
|
+
toast.add({ title: t('formats.format_created', 'Format created'), color: 'success' })
|
|
227
|
+
}
|
|
228
|
+
showModal.value = false
|
|
229
|
+
} catch (e: any) {
|
|
230
|
+
toast.add({ title: t('common.error', 'Error'), description: e.message, color: 'error' })
|
|
231
|
+
} finally {
|
|
232
|
+
saving.value = false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function remove(id: number) {
|
|
237
|
+
await deleteDatetimeFormat(id)
|
|
238
|
+
toast.add({ title: t('formats.format_deleted', 'Format deleted'), color: 'success' })
|
|
239
|
+
}
|
|
240
|
+
</script>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="p-6 max-w-4xl 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.modifiers_title', 'Custom modifiers') }}</h1>
|
|
6
|
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
7
|
+
{{ t('formats.modifiers_subtitle', 'Add custom') }} <code class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">@.modifier:key</code> {{ t('formats.modifiers_subtitle2', 'modifiers.') }}
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
<UButton icon="i-heroicons-plus" @click="openCreate">{{ t('formats.add_modifier', 'Add a modifier') }}</UButton>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<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">
|
|
14
|
+
<UIcon name="i-heroicons-information-circle" class="shrink-0 mt-0.5" />
|
|
15
|
+
<div>
|
|
16
|
+
{{ t('formats.modifiers_info', 'Define JS functions that receive a string and return a modified string. Example:') }} <code class="font-mono text-xs bg-blue-100 dark:bg-blue-900/40 px-1 rounded">snakeCase</code> → {{ t('formats.modifiers_info2', 'use') }} <code class="font-mono text-xs bg-blue-100 dark:bg-blue-900/40 px-1 rounded">@.snakeCase:common.title</code> {{ t('formats.modifiers_info3', 'in your translations.') }}
|
|
17
|
+
<NuxtLink to="https://vue-i18n.intlify.dev/guide/essentials/syntax.html#custom-modifiers" target="_blank" class="underline ml-1">{{ t('formats.documentation', 'Documentation') }} ↗</NuxtLink>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- Built-in modifiers reminder -->
|
|
22
|
+
<UCard>
|
|
23
|
+
<template #header>
|
|
24
|
+
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">{{ t('formats.builtin_modifiers', 'Built-in modifiers') }}</p>
|
|
25
|
+
</template>
|
|
26
|
+
<div class="grid grid-cols-3 gap-2">
|
|
27
|
+
<div v-for="mod in BUILTIN_MODIFIERS" :key="mod.name" class="bg-gray-50 dark:bg-gray-800 rounded p-2">
|
|
28
|
+
<code class="text-xs font-mono text-violet-600 dark:text-violet-400 font-semibold">@.{{ mod.name }}:</code>
|
|
29
|
+
<p class="text-xs text-gray-400 mt-0.5">{{ mod.desc }}</p>
|
|
30
|
+
<p class="text-xs font-mono text-gray-500 mt-1">{{ mod.example }}</p>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</UCard>
|
|
34
|
+
|
|
35
|
+
<!-- Empty -->
|
|
36
|
+
<div v-if="!modifiers?.length" class="text-center py-12 text-gray-400">
|
|
37
|
+
<UIcon name="i-heroicons-code-bracket" class="text-4xl mb-3" />
|
|
38
|
+
<p class="font-medium">{{ t('formats.no_modifier', 'No custom modifier') }}</p>
|
|
39
|
+
<p class="text-sm mt-1">{{ t('formats.add_custom_modifiers', 'Add your own text transformations.') }}</p>
|
|
40
|
+
<UButton class="mt-4" icon="i-heroicons-plus" @click="openCreate">{{ t('formats.add_modifier', 'Add a modifier') }}</UButton>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- List -->
|
|
44
|
+
<div v-else class="space-y-3">
|
|
45
|
+
<UCard v-for="mod in modifiers" :key="mod.id">
|
|
46
|
+
<div class="flex items-start justify-between gap-3">
|
|
47
|
+
<div class="min-w-0 flex-1">
|
|
48
|
+
<div class="flex items-center gap-2 mb-2">
|
|
49
|
+
<code class="text-sm font-mono font-semibold text-violet-600 dark:text-violet-400">@.{{ mod.name }}:</code>
|
|
50
|
+
<UBadge size="xs" color="violet" variant="soft">custom</UBadge>
|
|
51
|
+
</div>
|
|
52
|
+
<code class="text-xs font-mono text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 px-2 py-1 rounded block">{{ mod.body }}</code>
|
|
53
|
+
<!-- Live test -->
|
|
54
|
+
<div class="mt-2 flex items-center gap-2">
|
|
55
|
+
<UInput v-model="testInputs[mod.id]" size="xs" :placeholder="t('formats.test_placeholder', 'Test with a text...')" class="flex-1" />
|
|
56
|
+
<code class="text-xs font-mono text-gray-500 dark:text-gray-400 shrink-0">→ {{ testModifier(mod, testInputs[mod.id] || '') }}</code>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="flex gap-1 shrink-0">
|
|
60
|
+
<UButton icon="i-heroicons-pencil" size="xs" color="neutral" variant="ghost" @click="openEdit(mod)" />
|
|
61
|
+
<UButton icon="i-heroicons-trash" size="xs" color="error" variant="ghost" @click="remove(mod.id)" />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</UCard>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<!-- Create/Edit modal -->
|
|
68
|
+
<UModal v-model:open="showModal" :title="editing ? t('formats.edit_modifier', 'Edit modifier') : t('formats.new_modifier', 'New modifier')">
|
|
69
|
+
<template #body>
|
|
70
|
+
<div class="space-y-4">
|
|
71
|
+
<UFormField :label="t('formats.modifier_name', 'Name')" :hint="t('formats.modifier_name_hint', 'Used in @.name:key')" required>
|
|
72
|
+
<UInput v-model="form.name" placeholder="snakeCase" class="w-full" />
|
|
73
|
+
</UFormField>
|
|
74
|
+
|
|
75
|
+
<UFormField :label="t('formats.js_function', 'JavaScript function')" :hint="t('formats.js_function_hint', 'Arrow function receiving str and returning string')" required>
|
|
76
|
+
<UTextarea v-model="form.body" :rows="4" class="w-full font-mono text-sm" placeholder="(str) => str.split(' ').join('_')" />
|
|
77
|
+
</UFormField>
|
|
78
|
+
|
|
79
|
+
<!-- Quick templates -->
|
|
80
|
+
<div>
|
|
81
|
+
<p class="text-xs text-gray-400 mb-2">{{ t('formats.quick_templates', 'Quick templates:') }}</p>
|
|
82
|
+
<div class="flex flex-wrap gap-1.5">
|
|
83
|
+
<button
|
|
84
|
+
v-for="tpl in QUICK_TEMPLATES"
|
|
85
|
+
:key="tpl.name"
|
|
86
|
+
class="px-2 py-1 rounded text-xs border bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-violet-300 dark:hover:border-violet-600 text-gray-600 dark:text-gray-400 transition-colors"
|
|
87
|
+
@click="applyTemplate(tpl)"
|
|
88
|
+
>{{ tpl.name }}</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Test -->
|
|
93
|
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
|
|
94
|
+
<p class="text-xs text-gray-400">{{ t('formats.live_test', 'Live test:') }}</p>
|
|
95
|
+
<div class="flex items-center gap-2">
|
|
96
|
+
<UInput v-model="liveTest" size="xs" placeholder="Hello World" class="flex-1" />
|
|
97
|
+
<UIcon name="i-heroicons-arrow-right" class="text-gray-400 shrink-0" />
|
|
98
|
+
<code class="text-xs font-mono text-violet-600 dark:text-violet-400 shrink-0">{{ liveResult }}</code>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</template>
|
|
103
|
+
<template #footer>
|
|
104
|
+
<div class="flex justify-end gap-2">
|
|
105
|
+
<UButton color="neutral" variant="ghost" @click="showModal = false">{{ t('common.cancel', 'Cancel') }}</UButton>
|
|
106
|
+
<UButton :loading="saving" @click="save">{{ editing ? t('formats.update', 'Update') : t('common.create', 'Create') }}</UButton>
|
|
107
|
+
</div>
|
|
108
|
+
</template>
|
|
109
|
+
</UModal>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<script setup lang="ts">
|
|
114
|
+
const { modifiers, createModifier, updateModifier, deleteModifier } = useFormats()
|
|
115
|
+
const toast = useToast()
|
|
116
|
+
const { t } = useT()
|
|
117
|
+
|
|
118
|
+
const BUILTIN_MODIFIERS = [
|
|
119
|
+
{ name: 'lower', desc: t('formats.modifier_lower', 'Lowercase'), example: '"HELLO" → "hello"' },
|
|
120
|
+
{ name: 'upper', desc: t('formats.modifier_upper', 'UPPERCASE'), example: '"hello" → "HELLO"' },
|
|
121
|
+
{ name: 'capitalize', desc: t('formats.modifier_capitalize', '1st letter uppercase'), example: '"hello world" → "Hello world"' },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
const QUICK_TEMPLATES = [
|
|
125
|
+
{ name: 'snakeCase', body: '(str) => str.split(\' \').join(\'_\')' },
|
|
126
|
+
{ name: 'camelCase', body: '(str) => str.replace(/(?:^\\w|[A-Z]|\\b\\w|\\s+)/g, (m, i) => i === 0 ? m.toLowerCase() : m.toUpperCase()).replace(/\\s+/g, \'\')' },
|
|
127
|
+
{ name: 'kebabCase', body: '(str) => str.split(\' \').join(\'-\').toLowerCase()' },
|
|
128
|
+
{ name: 'titleCase', body: '(str) => str.replace(/\\b\\w/g, c => c.toUpperCase())' },
|
|
129
|
+
{ name: 'truncate', body: '(str) => str.length > 30 ? str.slice(0, 30) + \'…\' : str' },
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
const testInputs = ref<Record<number, string>>({})
|
|
133
|
+
const showModal = ref(false)
|
|
134
|
+
const saving = ref(false)
|
|
135
|
+
const editing = ref<any>(null)
|
|
136
|
+
const form = ref({ name: '', body: '' })
|
|
137
|
+
const liveTest = ref('Hello World')
|
|
138
|
+
|
|
139
|
+
const liveResult = computed(() => {
|
|
140
|
+
return testModifier({ body: form.value.body }, liveTest.value)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
function testModifier(mod: { body: string }, input: string): string {
|
|
144
|
+
if (!mod.body || !input) return ''
|
|
145
|
+
try {
|
|
146
|
+
// eslint-disable-next-line no-new-func
|
|
147
|
+
const fn = new Function('str', `return (${mod.body})(str)`)
|
|
148
|
+
return String(fn(input))
|
|
149
|
+
} catch {
|
|
150
|
+
return t('formats.error', '⚠ error')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function applyTemplate(tpl: { name: string; body: string }) {
|
|
155
|
+
form.value.name = form.value.name || tpl.name
|
|
156
|
+
form.value.body = tpl.body
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function openCreate() {
|
|
160
|
+
editing.value = null
|
|
161
|
+
form.value = { name: '', body: '' }
|
|
162
|
+
showModal.value = true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function openEdit(mod: any) {
|
|
166
|
+
editing.value = mod
|
|
167
|
+
form.value = { name: mod.name, body: mod.body }
|
|
168
|
+
showModal.value = true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function save() {
|
|
172
|
+
if (!form.value.name || !form.value.body) return
|
|
173
|
+
saving.value = true
|
|
174
|
+
try {
|
|
175
|
+
if (editing.value) {
|
|
176
|
+
await updateModifier(editing.value.id, form.value.name, form.value.body)
|
|
177
|
+
toast.add({ title: t('formats.modifier_updated', 'Modifier updated'), color: 'success' })
|
|
178
|
+
} else {
|
|
179
|
+
await createModifier(form.value.name, form.value.body)
|
|
180
|
+
toast.add({ title: t('formats.modifier_created', 'Modifier created'), color: 'success' })
|
|
181
|
+
}
|
|
182
|
+
showModal.value = false
|
|
183
|
+
} catch (e: any) {
|
|
184
|
+
toast.add({ title: t('common.error', 'Error'), description: e.message, color: 'error' })
|
|
185
|
+
} finally {
|
|
186
|
+
saving.value = false
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function remove(id: number) {
|
|
191
|
+
await deleteModifier(id)
|
|
192
|
+
toast.add({ title: t('formats.modifier_deleted', 'Modifier deleted'), color: 'success' })
|
|
193
|
+
}
|
|
194
|
+
</script>
|