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,541 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="grid border-b border-gray-100 dark:border-gray-800 last:border-0 group/row hover:bg-gray-50/50 dark:hover:bg-gray-800/30 transition-colors"
|
|
4
|
+
:class="translationKey.is_unused ? 'opacity-60' : ''"
|
|
5
|
+
:style="gridStyle"
|
|
6
|
+
>
|
|
7
|
+
<!-- Key column -->
|
|
8
|
+
<div class="px-4 py-3 flex flex-col justify-center min-w-0">
|
|
9
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
10
|
+
<UTooltip v-if="translationKey.is_unused" :text="t('translations.unused_tooltip', 'Key not found in source code')">
|
|
11
|
+
<UIcon name="i-heroicons-exclamation-triangle" class="text-orange-400 text-sm shrink-0" />
|
|
12
|
+
</UTooltip>
|
|
13
|
+
<NuxtLink
|
|
14
|
+
:to="projectId ? `/projects/${projectId}/translations/${translationKey.id}` : `/keys/${translationKey.id}`"
|
|
15
|
+
class="text-sm font-mono font-medium text-gray-900 dark:text-gray-100 truncate hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
|
16
|
+
>
|
|
17
|
+
{{ translationKey.key }}
|
|
18
|
+
</NuxtLink>
|
|
19
|
+
</div>
|
|
20
|
+
<!-- Description: inline edit -->
|
|
21
|
+
<template v-if="editingDescription">
|
|
22
|
+
<div class="flex items-center gap-1 mt-0.5">
|
|
23
|
+
<UInput
|
|
24
|
+
v-model="descriptionDraft"
|
|
25
|
+
size="xs"
|
|
26
|
+
class="flex-1 text-xs"
|
|
27
|
+
placeholder="Description…"
|
|
28
|
+
autofocus
|
|
29
|
+
@keydown.enter="saveDescription"
|
|
30
|
+
@keydown.escape="cancelDescription"
|
|
31
|
+
/>
|
|
32
|
+
<UButton size="xs" @click="saveDescription" :loading="savingDescription">OK</UButton>
|
|
33
|
+
<UButton size="xs" color="neutral" variant="ghost" @click="cancelDescription">✕</UButton>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
<template v-else>
|
|
37
|
+
<p
|
|
38
|
+
class="text-xs mt-0.5 truncate cursor-pointer"
|
|
39
|
+
:class="translationKey.description ? 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300' : 'text-gray-300 dark:text-gray-600 italic hover:text-primary-400'"
|
|
40
|
+
@click="startEditDescription"
|
|
41
|
+
>
|
|
42
|
+
{{ translationKey.description || t('translations.add_description', 'Add a description…') }}
|
|
43
|
+
</p>
|
|
44
|
+
</template>
|
|
45
|
+
<!-- Usage info -->
|
|
46
|
+
<div v-if="translationKey.usages?.length" class="flex items-center gap-1 mt-1">
|
|
47
|
+
<UTooltip :text="usageTooltip">
|
|
48
|
+
<button class="flex items-center gap-1 text-xs text-gray-400 hover:text-primary-500 transition-colors">
|
|
49
|
+
<UIcon name="i-heroicons-code-bracket" class="text-xs" />
|
|
50
|
+
<span>{{ translationKey.usages.length }} {{ translationKey.usages.length > 1 ? t('translations.references', 'references') : t('translations.reference', 'reference') }}</span>
|
|
51
|
+
</button>
|
|
52
|
+
</UTooltip>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- Translation columns -->
|
|
57
|
+
<div
|
|
58
|
+
v-for="lang in languages"
|
|
59
|
+
:key="lang.code"
|
|
60
|
+
class="px-3 py-2 flex items-start gap-2 min-w-0 group/cell"
|
|
61
|
+
>
|
|
62
|
+
<div class="flex-1 min-w-0">
|
|
63
|
+
<!-- Edit mode -->
|
|
64
|
+
<template v-if="editingCell === `${translationKey.id}-${lang.code}`">
|
|
65
|
+
<div :ref="el => textareaWrappers[lang.code] = el as HTMLElement">
|
|
66
|
+
<UTextarea
|
|
67
|
+
v-model="editValues[lang.code]"
|
|
68
|
+
:rows="2"
|
|
69
|
+
autofocus
|
|
70
|
+
class="text-sm w-full"
|
|
71
|
+
@keydown.ctrl.enter="saveTranslation(lang.code)"
|
|
72
|
+
@keydown.meta.enter="saveTranslation(lang.code)"
|
|
73
|
+
@keydown.escape="cancelEdit(lang.code)"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<!-- Insertion helpers toolbar -->
|
|
77
|
+
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1.5 pb-1.5 border-b border-gray-100 dark:border-gray-800">
|
|
78
|
+
<!-- Named params -->
|
|
79
|
+
<template v-if="detectedParams.length">
|
|
80
|
+
<div class="flex items-center gap-1 flex-wrap">
|
|
81
|
+
<span class="text-xs text-gray-400">{{ t('key.params_label', 'Params:') }}</span>
|
|
82
|
+
<button
|
|
83
|
+
v-for="param in detectedParams"
|
|
84
|
+
:key="param"
|
|
85
|
+
class="inline-flex items-center gap-0.5 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 dark:hover:bg-amber-900/50 transition-colors"
|
|
86
|
+
@mousedown.prevent="insertAtCursor(lang.code, '{' + param + '}')"
|
|
87
|
+
>
|
|
88
|
+
<UIcon name="i-heroicons-cursor-arrow-rays" class="text-xs opacity-60" />
|
|
89
|
+
{{ '{' + param + '}' }}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|
|
93
|
+
<!-- Literal / backslash escapes -->
|
|
94
|
+
<div class="flex items-center gap-1 flex-wrap">
|
|
95
|
+
<span class="text-xs text-gray-400">{{ t('key.escapes_label', 'Escapes:') }}</span>
|
|
96
|
+
<UTooltip v-for="esc in ALL_ESCAPES" :key="esc.insert" :text="esc.hint">
|
|
97
|
+
<button
|
|
98
|
+
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-700 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors"
|
|
99
|
+
@mousedown.prevent="insertAtCursor(lang.code, esc.insert)"
|
|
100
|
+
>{{ esc.label }}</button>
|
|
101
|
+
</UTooltip>
|
|
102
|
+
</div>
|
|
103
|
+
<!-- Modifiers -->
|
|
104
|
+
<div class="flex items-center gap-1 flex-wrap">
|
|
105
|
+
<span class="text-xs text-gray-400">{{ t('key.modifiers_label', 'Modifiers:') }}</span>
|
|
106
|
+
<UTooltip v-for="mod in LINK_MODIFIERS" :key="mod.prefix" :text="mod.hint">
|
|
107
|
+
<button
|
|
108
|
+
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-violet-50 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-700 hover:bg-violet-100 dark:hover:bg-violet-900/50 transition-colors"
|
|
109
|
+
@mousedown.prevent="insertAtCursor(lang.code, mod.prefix)"
|
|
110
|
+
>{{ mod.prefix }}</button>
|
|
111
|
+
</UTooltip>
|
|
112
|
+
</div>
|
|
113
|
+
<!-- Plural separator -->
|
|
114
|
+
<UTooltip :text="t('translations.insert_plural_sep', 'Insert a plural form separator | (e.g. car | cars)')">
|
|
115
|
+
<button
|
|
116
|
+
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-mono bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-700 hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors"
|
|
117
|
+
@mousedown.prevent="insertAtCursor(lang.code, ' | ')"
|
|
118
|
+
>
|
|
119
|
+
<UIcon name="i-heroicons-bars-3-bottom-left" class="text-xs opacity-60" />
|
|
120
|
+
{{ ' | ' }}
|
|
121
|
+
</button>
|
|
122
|
+
</UTooltip>
|
|
123
|
+
<!-- Linked key picker -->
|
|
124
|
+
<LinkedKeyPicker :project-id="projectId" @select="(val) => insertLinkedKey(lang.code, val)" />
|
|
125
|
+
</div>
|
|
126
|
+
<div class="flex gap-1 mt-1.5">
|
|
127
|
+
<UButton
|
|
128
|
+
size="xs"
|
|
129
|
+
@click="saveTranslation(lang.code)"
|
|
130
|
+
:loading="saving === `${translationKey.id}-${lang.code}`"
|
|
131
|
+
>
|
|
132
|
+
{{ t('translations.save', 'Save') }}
|
|
133
|
+
</UButton>
|
|
134
|
+
<UButton size="xs" color="neutral" variant="ghost" @click="cancelEdit(lang.code)">
|
|
135
|
+
{{ t('common.cancel', 'Cancel') }}
|
|
136
|
+
</UButton>
|
|
137
|
+
</div>
|
|
138
|
+
</template>
|
|
139
|
+
|
|
140
|
+
<!-- View mode -->
|
|
141
|
+
<template v-else>
|
|
142
|
+
<div class="flex items-start gap-1.5">
|
|
143
|
+
<!-- Status dot -->
|
|
144
|
+
<UTooltip :text="statusLabel(lang.code)" :delay-duration="300">
|
|
145
|
+
<span
|
|
146
|
+
class="mt-1.5 w-2 h-2 rounded-full shrink-0"
|
|
147
|
+
:class="[statusDot(lang.code), canClickStatus(lang.code) ? 'cursor-pointer' : 'cursor-default']"
|
|
148
|
+
@click="cycleStatus(lang.code)"
|
|
149
|
+
/>
|
|
150
|
+
</UTooltip>
|
|
151
|
+
|
|
152
|
+
<div class="flex-1 min-w-0 cursor-pointer" @click="startEdit(lang.code)">
|
|
153
|
+
<template v-if="getTranslation(lang.code)">
|
|
154
|
+
<!-- Plural: show forms stacked -->
|
|
155
|
+
<div v-if="getPluralCount(lang.code) > 1" class="space-y-0.5">
|
|
156
|
+
<div
|
|
157
|
+
v-for="(form, i) in getTranslation(lang.code).split(' | ')"
|
|
158
|
+
:key="i"
|
|
159
|
+
class="flex items-baseline gap-1.5"
|
|
160
|
+
>
|
|
161
|
+
<span class="text-xs font-mono text-green-500 shrink-0">[{{ i }}]</span>
|
|
162
|
+
<span class="text-sm text-gray-700 dark:text-gray-300 leading-snug truncate hover:text-primary-600 dark:hover:text-primary-400">{{ form.trim() }}</span>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<!-- Single value -->
|
|
166
|
+
<p
|
|
167
|
+
v-else
|
|
168
|
+
class="text-sm text-gray-700 dark:text-gray-300 leading-snug truncate hover:text-primary-600 dark:hover:text-primary-400"
|
|
169
|
+
>{{ getTranslation(lang.code) }}</p>
|
|
170
|
+
</template>
|
|
171
|
+
<span v-else class="text-xs text-gray-300 dark:text-gray-600 italic hover:text-primary-500 transition-colors">
|
|
172
|
+
{{ t('translations.click_to_add', 'Click to add…') }}
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<!-- Actions: Google Translate + status toggle -->
|
|
180
|
+
<div class="flex flex-col gap-1 opacity-0 group-hover/cell:opacity-100 transition-opacity shrink-0">
|
|
181
|
+
<UTooltip :text="hasSourceText ? t('translations.translate_to', 'Translate to') + ` ${lang.name}` : t('translations.no_source', 'No source available')">
|
|
182
|
+
<UButton
|
|
183
|
+
icon="i-heroicons-sparkles"
|
|
184
|
+
size="xs"
|
|
185
|
+
color="warning"
|
|
186
|
+
variant="ghost"
|
|
187
|
+
:disabled="!hasSourceText"
|
|
188
|
+
:loading="translateLoading === `${translationKey.id}-${lang.code}`"
|
|
189
|
+
@click="autoTranslate(lang)"
|
|
190
|
+
/>
|
|
191
|
+
</UTooltip>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<!-- Row actions -->
|
|
196
|
+
<div class="px-2 py-3 flex items-center justify-center">
|
|
197
|
+
<UDropdownMenu :items="rowActions">
|
|
198
|
+
<UButton
|
|
199
|
+
icon="i-heroicons-ellipsis-vertical"
|
|
200
|
+
size="xs"
|
|
201
|
+
color="neutral"
|
|
202
|
+
variant="ghost"
|
|
203
|
+
class="opacity-0 group-hover/row:opacity-100 transition-opacity"
|
|
204
|
+
/>
|
|
205
|
+
</UDropdownMenu>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<!-- History modal -->
|
|
210
|
+
<TranslationHistoryModal
|
|
211
|
+
v-if="showHistory"
|
|
212
|
+
:translation-id="historyTranslationId"
|
|
213
|
+
@close="showHistory = false"
|
|
214
|
+
/>
|
|
215
|
+
</template>
|
|
216
|
+
|
|
217
|
+
<script setup lang="ts">
|
|
218
|
+
import { TRANSLATION_STATUS } from '../server/enums/translation.enum'
|
|
219
|
+
|
|
220
|
+
const { t } = useT()
|
|
221
|
+
|
|
222
|
+
const props = defineProps<{
|
|
223
|
+
translationKey: {
|
|
224
|
+
id: number
|
|
225
|
+
key: string
|
|
226
|
+
description?: string
|
|
227
|
+
is_unused?: boolean
|
|
228
|
+
translations: Record<string, { id: number; value: string; language_code: string; status?: string } | undefined>
|
|
229
|
+
usages?: Array<{ file_path: string; line_number: number; detected_function: string }>
|
|
230
|
+
}
|
|
231
|
+
languages: Array<{ code: string; name: string }>
|
|
232
|
+
gridStyle: Record<string, string>
|
|
233
|
+
projectId?: number
|
|
234
|
+
}>()
|
|
235
|
+
|
|
236
|
+
const emit = defineEmits<{ updated: [] }>()
|
|
237
|
+
|
|
238
|
+
const toast = useToast()
|
|
239
|
+
const { canApprove, canManageProject } = useAuth()
|
|
240
|
+
|
|
241
|
+
const userCanApprove = computed(() => props.projectId ? canApprove(props.projectId) : false)
|
|
242
|
+
const userCanDelete = computed(() => props.projectId ? canManageProject(props.projectId) : false)
|
|
243
|
+
|
|
244
|
+
// Description inline edit
|
|
245
|
+
const editingDescription = ref(false)
|
|
246
|
+
const descriptionDraft = ref('')
|
|
247
|
+
const savingDescription = ref(false)
|
|
248
|
+
|
|
249
|
+
function startEditDescription() {
|
|
250
|
+
descriptionDraft.value = props.translationKey.description || ''
|
|
251
|
+
editingDescription.value = true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function cancelDescription() {
|
|
255
|
+
editingDescription.value = false
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function saveDescription() {
|
|
259
|
+
savingDescription.value = true
|
|
260
|
+
try {
|
|
261
|
+
await $fetch(`/api/keys/${props.translationKey.id}`, {
|
|
262
|
+
method: 'PATCH',
|
|
263
|
+
body: { description: descriptionDraft.value || null },
|
|
264
|
+
})
|
|
265
|
+
editingDescription.value = false
|
|
266
|
+
emit('updated')
|
|
267
|
+
} catch (e: any) {
|
|
268
|
+
toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
|
|
269
|
+
} finally {
|
|
270
|
+
savingDescription.value = false
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const editingCell = ref<string | null>(null)
|
|
275
|
+
const editValues = ref<Record<string, string>>({})
|
|
276
|
+
const saving = ref<string | null>(null)
|
|
277
|
+
const translateLoading = ref<string | null>(null)
|
|
278
|
+
const showHistory = ref(false)
|
|
279
|
+
const historyTranslationId = ref<number | null>(null)
|
|
280
|
+
const textareaWrappers = ref<Record<string, HTMLElement | null>>({})
|
|
281
|
+
|
|
282
|
+
// Literal interpolation {'x'} + backslash escapes (v11.3+), grouped
|
|
283
|
+
const ALL_ESCAPES = computed(() => [
|
|
284
|
+
{ label: `{'@'}`, insert: `{'@'}`, hint: t('key.escape_at', `@ → {'@'} — prevents link interpretation`) },
|
|
285
|
+
{ label: `{'{'}`, insert: `{'{'}`, hint: t('key.escape_open_brace', `{ → {'{'} — prevents interpolation opening`) },
|
|
286
|
+
{ label: `{'}'}`, insert: `{'}'}`, hint: t('key.escape_close_brace', `} → {'}'} — prevents interpolation closing`) },
|
|
287
|
+
{ label: `{'$'}`, insert: `{'$'}`, hint: t('key.escape_dollar', `$ → {'$'} — prevents modifier interpretation`) },
|
|
288
|
+
{ label: `{'|'}`, insert: `{'|'}`, hint: t('key.escape_pipe', `| → {'|'} — literal pipe (≠ plural separator)`) },
|
|
289
|
+
{ label: `\\{`, insert: `\\{`, hint: t('key.escape_backslash_open', `\\{ — backslash escape (v11.3+), alternative to {'{'}`) },
|
|
290
|
+
{ label: `\\}`, insert: `\\}`, hint: t('key.escape_backslash_close', `\\} — backslash escape (v11.3+), alternative to {'}'}`) },
|
|
291
|
+
{ label: `\\@`, insert: `\\@`, hint: t('key.escape_backslash_at', `\\@ — backslash escape (v11.3+), alternative to {'@'}`) },
|
|
292
|
+
{ label: `\\\\`, insert: `\\\\`, hint: t('key.escape_backslash', `\\\\ — literal backslash`) },
|
|
293
|
+
])
|
|
294
|
+
|
|
295
|
+
// Modifier prefixes for linked messages
|
|
296
|
+
const LINK_MODIFIERS = computed(() => [
|
|
297
|
+
{ prefix: '@:', hint: t('key.modifier_simple', '@:key — inserts the value as-is') },
|
|
298
|
+
{ prefix: '@.lower:', hint: t('key.modifier_lower', '@.lower:key — converts to lowercase') },
|
|
299
|
+
{ prefix: '@.upper:', hint: t('key.modifier_upper', '@.upper:key — converts to UPPERCASE') },
|
|
300
|
+
{ prefix: '@.capitalize:', hint: t('key.modifier_capitalize', '@.capitalize:key — capitalizes first letter') },
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
// Detect named/list params like {name}, {0} across all existing translations
|
|
304
|
+
const PARAM_REGEX = /\{([a-zA-Z_$][a-zA-Z0-9_\-$]*|\d+)\}/g
|
|
305
|
+
const detectedParams = computed(() => {
|
|
306
|
+
const params = new Set<string>()
|
|
307
|
+
for (const tr of Object.values(props.translationKey.translations)) {
|
|
308
|
+
if (!tr?.value) continue
|
|
309
|
+
for (const match of tr.value.matchAll(PARAM_REGEX)) {
|
|
310
|
+
params.add(match[1])
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return [...params]
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
function insertAtCursor(langCode: string, insertion: string) {
|
|
317
|
+
const wrapper = textareaWrappers.value[langCode]
|
|
318
|
+
const textarea = wrapper?.querySelector('textarea')
|
|
319
|
+
const current = editValues.value[langCode] || ''
|
|
320
|
+
|
|
321
|
+
if (textarea) {
|
|
322
|
+
const start = textarea.selectionStart ?? current.length
|
|
323
|
+
const end = textarea.selectionEnd ?? current.length
|
|
324
|
+
editValues.value[langCode] = current.slice(0, start) + insertion + current.slice(end)
|
|
325
|
+
nextTick(() => {
|
|
326
|
+
textarea.focus()
|
|
327
|
+
textarea.selectionStart = textarea.selectionEnd = start + insertion.length
|
|
328
|
+
})
|
|
329
|
+
} else {
|
|
330
|
+
editValues.value[langCode] = current + insertion
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function insertLinkedKey(langCode: string, value: string) {
|
|
335
|
+
insertAtCursor(langCode, value)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function getTranslation(langCode: string): string {
|
|
339
|
+
return props.translationKey.translations[langCode]?.value || ''
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getPluralCount(langCode: string): number {
|
|
343
|
+
const val = getTranslation(langCode)
|
|
344
|
+
return val ? val.split(' | ').length : 0
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getStatus(langCode: string): TRANSLATION_STATUS | null {
|
|
348
|
+
const tr = props.translationKey.translations[langCode]
|
|
349
|
+
if (!tr?.value) return null
|
|
350
|
+
return (tr.status as TRANSLATION_STATUS) || TRANSLATION_STATUS.DRAFT
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function statusDot(langCode: string): string {
|
|
354
|
+
const status = getStatus(langCode)
|
|
355
|
+
if (!status) return 'bg-gray-200 dark:bg-gray-700'
|
|
356
|
+
const map: Record<TRANSLATION_STATUS, string> = {
|
|
357
|
+
[TRANSLATION_STATUS.DRAFT]: 'bg-yellow-400',
|
|
358
|
+
[TRANSLATION_STATUS.REVIEWED]: 'bg-blue-400',
|
|
359
|
+
[TRANSLATION_STATUS.APPROVED]: 'bg-green-500',
|
|
360
|
+
[TRANSLATION_STATUS.REJECTED]: 'bg-red-400',
|
|
361
|
+
}
|
|
362
|
+
return map[status] || 'bg-gray-200'
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function statusLabel(langCode: string): string {
|
|
366
|
+
const status = getStatus(langCode)
|
|
367
|
+
if (!status) return t('translations.status_missing', 'Missing — click to add')
|
|
368
|
+
if (status === TRANSLATION_STATUS.DRAFT) return userCanApprove.value
|
|
369
|
+
? t('translations.status_draft_approver', 'Draft — click to mark as reviewed')
|
|
370
|
+
: t('translations.status_draft_translator', 'Draft — click to mark as reviewed')
|
|
371
|
+
if (status === TRANSLATION_STATUS.REVIEWED) return userCanApprove.value
|
|
372
|
+
? t('translations.status_reviewed_approver', 'Reviewed — click to approve')
|
|
373
|
+
: t('translations.status_reviewed_translator', 'Reviewed (approval reserved for moderators)')
|
|
374
|
+
if (status === TRANSLATION_STATUS.APPROVED) return userCanApprove.value
|
|
375
|
+
? t('translations.status_approved_approver', 'Approved — click to revert to draft')
|
|
376
|
+
: t('translations.status_approved', 'Approved')
|
|
377
|
+
if (status === TRANSLATION_STATUS.REJECTED) return t('status.rejected', 'Rejected')
|
|
378
|
+
return status
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function canClickStatus(langCode: string): boolean {
|
|
382
|
+
const tr = props.translationKey.translations[langCode]
|
|
383
|
+
if (!tr?.value) return false
|
|
384
|
+
if (!userCanApprove.value && tr.status === TRANSLATION_STATUS.APPROVED) return false
|
|
385
|
+
return true
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function cycleStatus(langCode: string) {
|
|
389
|
+
const tr = props.translationKey.translations[langCode]
|
|
390
|
+
if (!tr?.value) return
|
|
391
|
+
|
|
392
|
+
const current = (tr.status as TRANSLATION_STATUS) || TRANSLATION_STATUS.DRAFT
|
|
393
|
+
let next: TRANSLATION_STATUS
|
|
394
|
+
if (userCanApprove.value) {
|
|
395
|
+
// moderator+ can cycle through all statuses
|
|
396
|
+
next = current === TRANSLATION_STATUS.DRAFT ? TRANSLATION_STATUS.REVIEWED : current === TRANSLATION_STATUS.REVIEWED ? TRANSLATION_STATUS.APPROVED : TRANSLATION_STATUS.DRAFT
|
|
397
|
+
} else {
|
|
398
|
+
// translator can only toggle draft ↔ reviewed
|
|
399
|
+
if (current === TRANSLATION_STATUS.APPROVED) return // cannot change approved
|
|
400
|
+
next = current === TRANSLATION_STATUS.DRAFT ? TRANSLATION_STATUS.REVIEWED : TRANSLATION_STATUS.DRAFT
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
await $fetch('/api/translations/status', {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
body: { key_id: props.translationKey.id, language_code: langCode, status: next },
|
|
407
|
+
})
|
|
408
|
+
emit('updated')
|
|
409
|
+
refreshNuxtData('project-stats')
|
|
410
|
+
} catch (e: any) {
|
|
411
|
+
toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const hasSourceText = computed(() =>
|
|
416
|
+
Object.values(props.translationKey.translations).some((tr) => tr?.value),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
function getSourceText(): { text: string; lang: string } | null {
|
|
420
|
+
const entries = Object.entries(props.translationKey.translations)
|
|
421
|
+
const withValue = entries.filter(([, tr]) => tr?.value)
|
|
422
|
+
if (!withValue.length) return null
|
|
423
|
+
return { text: withValue[0][1]!.value, lang: withValue[0][0] }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const usageTooltip = computed(() => {
|
|
427
|
+
const usages = props.translationKey.usages || []
|
|
428
|
+
// Group by file
|
|
429
|
+
const byFile = usages.reduce((acc: Record<string, number[]>, u) => {
|
|
430
|
+
if (!acc[u.file_path]) acc[u.file_path] = []
|
|
431
|
+
acc[u.file_path].push(u.line_number)
|
|
432
|
+
return acc
|
|
433
|
+
}, {})
|
|
434
|
+
return Object.entries(byFile)
|
|
435
|
+
.slice(0, 5)
|
|
436
|
+
.map(([file, lines]) => `${file}:${lines.join(',')}`)
|
|
437
|
+
.join('\n')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
function startEdit(langCode: string) {
|
|
441
|
+
editingCell.value = `${props.translationKey.id}-${langCode}`
|
|
442
|
+
editValues.value[langCode] = getTranslation(langCode)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function cancelEdit(langCode: string) {
|
|
446
|
+
editingCell.value = null
|
|
447
|
+
delete editValues.value[langCode]
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function saveTranslation(langCode: string) {
|
|
451
|
+
saving.value = `${props.translationKey.id}-${langCode}`
|
|
452
|
+
try {
|
|
453
|
+
await $fetch('/api/translations', {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
body: {
|
|
456
|
+
key_id: props.translationKey.id,
|
|
457
|
+
language_code: langCode,
|
|
458
|
+
value: editValues.value[langCode],
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
editingCell.value = null
|
|
462
|
+
emit('updated')
|
|
463
|
+
} catch (e: any) {
|
|
464
|
+
toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
|
|
465
|
+
} finally {
|
|
466
|
+
saving.value = null
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function autoTranslate(lang: { code: string; name: string }) {
|
|
471
|
+
const source = getSourceText()
|
|
472
|
+
if (!source) return
|
|
473
|
+
|
|
474
|
+
translateLoading.value = `${props.translationKey.id}-${lang.code}`
|
|
475
|
+
try {
|
|
476
|
+
await $fetch('/api/translate', {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
body: {
|
|
479
|
+
text: source.text,
|
|
480
|
+
from: source.lang,
|
|
481
|
+
to: lang.code,
|
|
482
|
+
key_id: props.translationKey.id,
|
|
483
|
+
language_code: lang.code,
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
toast.add({ title: t('translations.translated', 'Translated'), description: `${props.translationKey.key} → ${lang.name}`, color: 'success' })
|
|
487
|
+
emit('updated')
|
|
488
|
+
} catch (e: any) {
|
|
489
|
+
toast.add({ title: t('translations.translate_error', 'Google Translate error'), description: e.data?.message || e.message, color: 'error' })
|
|
490
|
+
} finally {
|
|
491
|
+
translateLoading.value = null
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function viewHistory(langCode: string) {
|
|
496
|
+
const tr = props.translationKey.translations[langCode]
|
|
497
|
+
if (tr) {
|
|
498
|
+
historyTranslationId.value = tr.id
|
|
499
|
+
showHistory.value = true
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const rowActions = computed(() => {
|
|
504
|
+
const groups: any[] = [
|
|
505
|
+
[
|
|
506
|
+
{
|
|
507
|
+
label: t('translations.history_by_lang', 'History by language'),
|
|
508
|
+
type: 'label' as const,
|
|
509
|
+
},
|
|
510
|
+
...props.languages.map((lang) => ({
|
|
511
|
+
label: `${lang.name} (${lang.code})`,
|
|
512
|
+
icon: 'i-heroicons-clock',
|
|
513
|
+
disabled: !props.translationKey.translations[lang.code],
|
|
514
|
+
onSelect: () => viewHistory(lang.code),
|
|
515
|
+
})),
|
|
516
|
+
],
|
|
517
|
+
]
|
|
518
|
+
if (userCanDelete.value) {
|
|
519
|
+
groups.push([
|
|
520
|
+
{
|
|
521
|
+
label: t('translations.delete_key', 'Delete key'),
|
|
522
|
+
icon: 'i-heroicons-trash',
|
|
523
|
+
color: 'error' as const,
|
|
524
|
+
onSelect: deleteKey,
|
|
525
|
+
},
|
|
526
|
+
])
|
|
527
|
+
}
|
|
528
|
+
return groups
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
async function deleteKey() {
|
|
532
|
+
try {
|
|
533
|
+
await $fetch(`/api/keys/${props.translationKey.id}`, { method: 'DELETE' })
|
|
534
|
+
toast.add({ title: t('translations.key_deleted', 'Key deleted'), color: 'success' })
|
|
535
|
+
emit('updated')
|
|
536
|
+
refreshNuxtData('project-stats')
|
|
537
|
+
} catch (e: any) {
|
|
538
|
+
toast.add({ title: t('common.error', 'Error'), description: e.data?.message || e.message, color: 'error' })
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
</script>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { PropType } from 'vue'
|
|
3
|
+
import type { WidgetConfig, WidgetDataSource, DataSourceType } from '~/types/dashboard.type'
|
|
4
|
+
import { WIDGET_REGISTRY } from '~/consts/dashboard.const'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
open: {
|
|
8
|
+
type: Boolean,
|
|
9
|
+
required: true,
|
|
10
|
+
},
|
|
11
|
+
widget: {
|
|
12
|
+
type: Object as PropType<WidgetConfig | null>,
|
|
13
|
+
default: null,
|
|
14
|
+
},
|
|
15
|
+
index: {
|
|
16
|
+
type: Number,
|
|
17
|
+
default: -1,
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
(e: 'update:open', value: boolean): void
|
|
23
|
+
(e: 'save', value: { dataSource: WidgetDataSource | undefined; title: string | undefined }): void
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const { t } = useT()
|
|
27
|
+
const { visibleProjects } = useProject()
|
|
28
|
+
|
|
29
|
+
const draftSource = ref<DataSourceType>('global')
|
|
30
|
+
const draftProjectId = ref<number | undefined>()
|
|
31
|
+
const draftTitle = ref('')
|
|
32
|
+
|
|
33
|
+
watch(
|
|
34
|
+
() => props.widget,
|
|
35
|
+
(w) => {
|
|
36
|
+
if (!w) return
|
|
37
|
+
draftSource.value = w.dataSource?.type ?? 'global'
|
|
38
|
+
draftProjectId.value = w.dataSource?.projectId
|
|
39
|
+
draftTitle.value = w.title ?? ''
|
|
40
|
+
},
|
|
41
|
+
{ immediate: true },
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const projectItems = computed(() =>
|
|
45
|
+
visibleProjects.value
|
|
46
|
+
.filter((p: any) => !p.is_system)
|
|
47
|
+
.map((p: any) => ({ label: p.name, value: p.id })),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const hasDataSource = computed(() => {
|
|
51
|
+
if (!props.widget) return false
|
|
52
|
+
return WIDGET_REGISTRY[props.widget.type].hasDataSource
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function save() {
|
|
56
|
+
const dataSource: WidgetDataSource = draftSource.value === 'project'
|
|
57
|
+
? { type: 'project', projectId: draftProjectId.value }
|
|
58
|
+
: { type: 'global' }
|
|
59
|
+
emit('save', { dataSource, title: draftTitle.value || undefined })
|
|
60
|
+
emit('update:open', false)
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<UModal :open="open" :title="t('dashboard.configure_widget', 'Configure widget')" @update:open="emit('update:open', $event)">
|
|
66
|
+
<template #body>
|
|
67
|
+
<div class="space-y-6">
|
|
68
|
+
<div v-if="hasDataSource" class="space-y-3">
|
|
69
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.data_source', 'Data source') }}</p>
|
|
70
|
+
<div class="flex gap-2">
|
|
71
|
+
<button
|
|
72
|
+
class="flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors"
|
|
73
|
+
:class="draftSource === 'global'
|
|
74
|
+
? 'bg-primary-500 text-white border-primary-500'
|
|
75
|
+
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
|
76
|
+
@click="draftSource = 'global'"
|
|
77
|
+
>
|
|
78
|
+
Global
|
|
79
|
+
</button>
|
|
80
|
+
<button
|
|
81
|
+
class="flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors"
|
|
82
|
+
:class="draftSource === 'project'
|
|
83
|
+
? 'bg-primary-500 text-white border-primary-500'
|
|
84
|
+
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
|
85
|
+
@click="draftSource = 'project'"
|
|
86
|
+
>
|
|
87
|
+
{{ t('dashboard.specific_project', 'Specific project') }}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div v-if="draftSource === 'project'">
|
|
92
|
+
<USelect
|
|
93
|
+
v-model="draftProjectId"
|
|
94
|
+
:items="projectItems"
|
|
95
|
+
:placeholder="t('dashboard.select_project', 'Select a project')"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="space-y-2">
|
|
101
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.custom_title', 'Custom title') }}</p>
|
|
102
|
+
<UInput
|
|
103
|
+
v-model="draftTitle"
|
|
104
|
+
:placeholder="t('dashboard.default_title', 'Default title')"
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<template #footer>
|
|
111
|
+
<div class="flex justify-end gap-2">
|
|
112
|
+
<UButton variant="ghost" color="neutral" @click="emit('update:open', false)">
|
|
113
|
+
{{ t('common.cancel', 'Cancel') }}
|
|
114
|
+
</UButton>
|
|
115
|
+
<UButton @click="save">
|
|
116
|
+
{{ t('common.save', 'Save') }}
|
|
117
|
+
</UButton>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
</UModal>
|
|
121
|
+
</template>
|