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,642 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<div v-if="pending" class="p-4 lg:p-6 max-w-6xl mx-auto space-y-4">
|
|
4
|
+
<div class="flex items-start gap-3">
|
|
5
|
+
<USkeleton class="w-8 h-8 rounded-lg shrink-0 mt-0.5" />
|
|
6
|
+
<div class="flex-1 space-y-2">
|
|
7
|
+
<USkeleton class="h-6 w-1/2" />
|
|
8
|
+
<USkeleton class="h-3 w-1/4" />
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6 items-start">
|
|
12
|
+
<div class="lg:col-span-2 space-y-3">
|
|
13
|
+
<UCard v-for="i in 3" :key="i">
|
|
14
|
+
<template #header>
|
|
15
|
+
<div class="flex items-center justify-between">
|
|
16
|
+
<div class="flex items-center gap-2">
|
|
17
|
+
<USkeleton class="h-5 w-10 rounded" />
|
|
18
|
+
<USkeleton class="h-4 w-24" />
|
|
19
|
+
</div>
|
|
20
|
+
<USkeleton class="h-5 w-16 rounded" />
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
23
|
+
<USkeleton class="h-12 w-full rounded-lg" />
|
|
24
|
+
</UCard>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="space-y-4">
|
|
27
|
+
<UCard>
|
|
28
|
+
<template #header><USkeleton class="h-4 w-24" /></template>
|
|
29
|
+
<USkeleton class="h-16 w-full" />
|
|
30
|
+
</UCard>
|
|
31
|
+
<UCard>
|
|
32
|
+
<template #header><USkeleton class="h-4 w-8" /></template>
|
|
33
|
+
<div class="space-y-2">
|
|
34
|
+
<USkeleton class="h-3 w-3/4" />
|
|
35
|
+
<USkeleton class="h-3 w-1/2" />
|
|
36
|
+
<USkeleton class="h-3 w-2/3" />
|
|
37
|
+
</div>
|
|
38
|
+
</UCard>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div v-else-if="keyData" class="p-4 lg:p-6 max-w-6xl mx-auto space-y-4">
|
|
44
|
+
|
|
45
|
+
<!-- Header -->
|
|
46
|
+
<div class="flex items-start gap-3">
|
|
47
|
+
<UButton icon="i-heroicons-arrow-left" color="neutral" variant="ghost" size="xs" :to="`/projects/${projectId}/translations`" class="mt-0.5 shrink-0" />
|
|
48
|
+
<div class="flex-1 min-w-0">
|
|
49
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
50
|
+
<h1 class="text-lg font-mono font-bold text-gray-900 dark:text-white break-all">{{ keyData.key }}</h1>
|
|
51
|
+
<UBadge v-if="keyData.is_unused" color="warning" variant="subtle" size="xs">
|
|
52
|
+
<UIcon name="i-heroicons-exclamation-triangle" class="mr-1" />
|
|
53
|
+
{{ t('status.unused', 'Unused') }}
|
|
54
|
+
</UBadge>
|
|
55
|
+
</div>
|
|
56
|
+
<p class="text-xs text-gray-400 mt-0.5">
|
|
57
|
+
{{ coverageCount }} / {{ keyData.languages.length }} {{ t('translations.langs_count', 'languages') }}
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
<UDropdownMenu v-if="keyActions[0]?.length" :items="keyActions">
|
|
61
|
+
<UButton icon="i-heroicons-ellipsis-vertical" color="neutral" variant="ghost" size="sm" />
|
|
62
|
+
</UDropdownMenu>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<!-- Body: 2 columns -->
|
|
66
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6 items-start">
|
|
67
|
+
|
|
68
|
+
<!-- ── Left : Translation cards ───────────────────────────────────── -->
|
|
69
|
+
<div class="lg:col-span-2 space-y-3">
|
|
70
|
+
<UCard v-for="lang in keyData.languages" :key="lang.code">
|
|
71
|
+
<template #header>
|
|
72
|
+
<div class="flex items-center justify-between gap-2">
|
|
73
|
+
<div class="flex items-center gap-2">
|
|
74
|
+
<span class="font-mono text-xs font-bold bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-gray-500 dark:text-gray-400 uppercase">{{ lang.code }}</span>
|
|
75
|
+
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ findLanguage(lang.code)?.nativeName || lang.name }}</span>
|
|
76
|
+
<UBadge v-if="lang.is_default" color="primary" variant="soft" size="xs">{{ t('languages.default_badge', 'Default') }}</UBadge>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="flex items-center gap-1">
|
|
79
|
+
<UBadge :color="statusColor(lang.code)" variant="soft" size="xs">{{ statusLabel(lang.code) }}</UBadge>
|
|
80
|
+
<UTooltip :text="sourceText ? `${t('translations.translate_to', 'Translate to')} ${findLanguage(lang.code)?.nativeName || lang.name}` : t('translations.no_source', 'No source available')">
|
|
81
|
+
<UButton
|
|
82
|
+
icon="i-heroicons-sparkles"
|
|
83
|
+
color="warning"
|
|
84
|
+
variant="ghost"
|
|
85
|
+
size="xs"
|
|
86
|
+
:disabled="!sourceText || editingLang === lang.code"
|
|
87
|
+
:loading="translating === lang.code"
|
|
88
|
+
@click="autoTranslate(lang)"
|
|
89
|
+
/>
|
|
90
|
+
</UTooltip>
|
|
91
|
+
<UDropdownMenu :items="statusActions(lang.code)">
|
|
92
|
+
<UButton icon="i-heroicons-ellipsis-vertical" color="neutral" variant="ghost" size="xs" />
|
|
93
|
+
</UDropdownMenu>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<div class="space-y-3">
|
|
99
|
+
<!-- Rejected notice -->
|
|
100
|
+
<div v-if="getStatus(lang.code) === 'rejected'" class="flex items-center gap-2 text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2">
|
|
101
|
+
<UIcon name="i-heroicons-x-circle" class="shrink-0" />
|
|
102
|
+
{{ t('key.rejected_notice', 'This translation was rejected. Please update it.') }}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<!-- Edit mode -->
|
|
106
|
+
<template v-if="editingLang === lang.code">
|
|
107
|
+
|
|
108
|
+
<!-- Plural editor -->
|
|
109
|
+
<PluralEditor v-if="pluralMode" v-model="editValue" />
|
|
110
|
+
|
|
111
|
+
<!-- Single textarea -->
|
|
112
|
+
<div v-else :ref="el => activeTextareaWrapper = el as HTMLElement">
|
|
113
|
+
<UTextarea
|
|
114
|
+
v-model="editValue"
|
|
115
|
+
:rows="3"
|
|
116
|
+
autofocus
|
|
117
|
+
class="w-full"
|
|
118
|
+
@keydown.ctrl.enter="saveTranslation(lang.code)"
|
|
119
|
+
@keydown.meta.enter="saveTranslation(lang.code)"
|
|
120
|
+
@keydown.escape="editingLang = null"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- Insertion helpers toolbar -->
|
|
125
|
+
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mt-2 pb-1 border-b border-gray-100 dark:border-gray-800">
|
|
126
|
+
<!-- Named params -->
|
|
127
|
+
<template v-if="detectedParams.length">
|
|
128
|
+
<div class="flex items-center gap-1 flex-wrap">
|
|
129
|
+
<span class="text-xs text-gray-400">{{ t('key.params_label', 'Params:') }}</span>
|
|
130
|
+
<button
|
|
131
|
+
v-for="param in detectedParams"
|
|
132
|
+
:key="param"
|
|
133
|
+
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"
|
|
134
|
+
@mousedown.prevent="insertAtCursor('{' + param + '}')"
|
|
135
|
+
>
|
|
136
|
+
<UIcon name="i-heroicons-cursor-arrow-rays" class="text-xs opacity-60" />
|
|
137
|
+
{{ '{' + param + '}' }}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</template>
|
|
141
|
+
|
|
142
|
+
<!-- Literal / escape special chars -->
|
|
143
|
+
<div class="flex items-center gap-1 flex-wrap">
|
|
144
|
+
<span class="text-xs text-gray-400">{{ t('key.escapes_label', 'Escapes:') }}</span>
|
|
145
|
+
<UTooltip v-for="esc in ALL_ESCAPES" :key="esc.insert" :text="esc.hint">
|
|
146
|
+
<button
|
|
147
|
+
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"
|
|
148
|
+
@mousedown.prevent="insertAtCursor(esc.insert)"
|
|
149
|
+
>{{ esc.label }}</button>
|
|
150
|
+
</UTooltip>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<!-- Modifiers -->
|
|
154
|
+
<div class="flex items-center gap-1 flex-wrap">
|
|
155
|
+
<span class="text-xs text-gray-400">{{ t('key.modifiers_label', 'Modifiers:') }}</span>
|
|
156
|
+
<UTooltip v-for="mod in LINK_MODIFIERS" :key="mod.prefix" :text="mod.hint">
|
|
157
|
+
<button
|
|
158
|
+
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"
|
|
159
|
+
@mousedown.prevent="insertAtCursor(mod.prefix)"
|
|
160
|
+
>{{ mod.prefix }}</button>
|
|
161
|
+
</UTooltip>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<!-- Linked key -->
|
|
165
|
+
<LinkedKeyPicker :project-id="currentProject?.id" @select="insertLinkedKey" />
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<!-- Actions row -->
|
|
169
|
+
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
|
170
|
+
<UButton size="xs" :loading="saving === lang.code" @click="saveTranslation(lang.code)">
|
|
171
|
+
{{ t('translations.save', 'Save') }}
|
|
172
|
+
</UButton>
|
|
173
|
+
<UButton size="xs" color="neutral" variant="ghost" @click="editingLang = null">
|
|
174
|
+
{{ t('translations.cancel', 'Cancel') }}
|
|
175
|
+
</UButton>
|
|
176
|
+
<div class="ml-auto flex items-center gap-1.5">
|
|
177
|
+
<UTooltip :text="pluralMode ? t('key.back_to_simple', 'Back to simple edit') : t('key.switch_to_plural', 'Switch to plural editor (form1 | form2 | …)')" >
|
|
178
|
+
<UButton
|
|
179
|
+
size="xs"
|
|
180
|
+
:color="pluralMode ? 'success' : 'neutral'"
|
|
181
|
+
:variant="pluralMode ? 'soft' : 'ghost'"
|
|
182
|
+
icon="i-heroicons-bars-3-bottom-left"
|
|
183
|
+
@click="togglePluralMode(lang.code)"
|
|
184
|
+
>
|
|
185
|
+
{{ pluralMode ? `${editValue.split(' | ').length} ${t('key.forms', 'forms')}` : t('key.plural', 'Plural') }}
|
|
186
|
+
</UButton>
|
|
187
|
+
</UTooltip>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<!-- Read mode -->
|
|
193
|
+
<template v-else>
|
|
194
|
+
<!-- Plural display -->
|
|
195
|
+
<div
|
|
196
|
+
v-if="getTranslationValue(lang.code) && getPluralForms(lang.code).length > 1"
|
|
197
|
+
class="cursor-pointer group"
|
|
198
|
+
@click="startEdit(lang)"
|
|
199
|
+
>
|
|
200
|
+
<div class="flex items-center gap-1.5 mb-1.5">
|
|
201
|
+
<UBadge color="success" variant="soft" size="xs" icon="i-heroicons-bars-3-bottom-left">
|
|
202
|
+
{{ getPluralForms(lang.code).length }} {{ t('key.plural_forms', 'plural forms') }}
|
|
203
|
+
</UBadge>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="space-y-1">
|
|
206
|
+
<div
|
|
207
|
+
v-for="(form, i) in getPluralForms(lang.code)"
|
|
208
|
+
:key="i"
|
|
209
|
+
class="flex items-start gap-2 px-2 py-1 rounded group-hover:bg-gray-50 dark:group-hover:bg-gray-800/60 transition-colors"
|
|
210
|
+
>
|
|
211
|
+
<span class="text-xs font-mono text-green-500 shrink-0 w-10 pt-0.5">{{ PLURAL_FORM_LABELS[i] ?? `[${i}]` }}</span>
|
|
212
|
+
<span class="text-sm text-gray-700 dark:text-gray-300 leading-snug">{{ form.trim() }}</span>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
<!-- Single value display -->
|
|
217
|
+
<p
|
|
218
|
+
v-else-if="getTranslationValue(lang.code)"
|
|
219
|
+
class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 whitespace-pre-wrap px-2 py-1.5 rounded hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors"
|
|
220
|
+
@click="startEdit(lang)"
|
|
221
|
+
>{{ getTranslationValue(lang.code) }}</p>
|
|
222
|
+
<button
|
|
223
|
+
v-else
|
|
224
|
+
class="text-sm text-gray-400 italic hover:text-primary-500 transition-colors px-2 py-1.5"
|
|
225
|
+
@click="startEdit(lang)"
|
|
226
|
+
>
|
|
227
|
+
{{ t('translations.click_to_add', 'Click to add...') }}
|
|
228
|
+
</button>
|
|
229
|
+
</template>
|
|
230
|
+
|
|
231
|
+
<!-- History -->
|
|
232
|
+
<div v-if="keyData.translations[lang.code]?.history?.length" class="border-t border-gray-100 dark:border-gray-800 pt-3">
|
|
233
|
+
<button
|
|
234
|
+
class="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors mb-2"
|
|
235
|
+
@click="toggleHistory(lang.code)"
|
|
236
|
+
>
|
|
237
|
+
<UIcon :name="expandedHistory.includes(lang.code) ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
|
|
238
|
+
{{ t('translations.history', 'History') }} · {{ keyData.translations[lang.code].history.length }}
|
|
239
|
+
</button>
|
|
240
|
+
|
|
241
|
+
<div v-if="expandedHistory.includes(lang.code)" class="space-y-2">
|
|
242
|
+
<div
|
|
243
|
+
v-for="entry in keyData.translations[lang.code].history"
|
|
244
|
+
:key="entry.id"
|
|
245
|
+
class="group flex items-start gap-2 text-xs p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50"
|
|
246
|
+
>
|
|
247
|
+
<div class="flex-1 min-w-0 space-y-1">
|
|
248
|
+
<div class="flex items-center gap-2 text-gray-400">
|
|
249
|
+
<UIcon name="i-heroicons-clock" />
|
|
250
|
+
<span>{{ formatDate(entry.changed_at) }}</span>
|
|
251
|
+
<UBadge color="neutral" variant="soft" size="xs">{{ entry.changed_by || 'user' }}</UBadge>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="flex gap-2 items-start flex-wrap">
|
|
254
|
+
<span v-if="entry.old_value" class="line-through text-red-400 max-w-xs truncate">{{ entry.old_value }}</span>
|
|
255
|
+
<UIcon v-if="entry.old_value" name="i-heroicons-arrow-right" class="text-gray-300 shrink-0 mt-0.5" />
|
|
256
|
+
<span class="text-gray-700 dark:text-gray-300 break-words">{{ entry.new_value }}</span>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
<UButton
|
|
260
|
+
v-if="entry.new_value && entry.new_value !== getTranslationValue(lang.code)"
|
|
261
|
+
size="xs"
|
|
262
|
+
color="neutral"
|
|
263
|
+
variant="soft"
|
|
264
|
+
icon="i-heroicons-arrow-uturn-left"
|
|
265
|
+
class="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
266
|
+
:loading="restoring === `${lang.code}-${entry.id}`"
|
|
267
|
+
@click="restoreVersion(lang.code, entry)"
|
|
268
|
+
>
|
|
269
|
+
{{ t('key.restore', 'Restore') }}
|
|
270
|
+
</UButton>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</UCard>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<!-- ── Right : Key info ───────────────────────────────────────────── -->
|
|
279
|
+
<div class="space-y-4">
|
|
280
|
+
|
|
281
|
+
<!-- Description -->
|
|
282
|
+
<UCard>
|
|
283
|
+
<template #header>
|
|
284
|
+
<div class="flex items-center justify-between">
|
|
285
|
+
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">{{ t('translations.description_label', 'Description') }}</p>
|
|
286
|
+
<UButton v-if="!editingDescription" icon="i-heroicons-pencil" color="neutral" variant="ghost" size="xs" @click="startEditDescription" />
|
|
287
|
+
</div>
|
|
288
|
+
</template>
|
|
289
|
+
<template v-if="editingDescription">
|
|
290
|
+
<UTextarea v-model="descriptionDraft" :placeholder="t('translations.add_description', 'Add a description…')" :rows="3" autofocus class="w-full" />
|
|
291
|
+
<div class="flex gap-2 mt-2">
|
|
292
|
+
<UButton size="xs" :loading="savingDescription" @click="saveDescription">{{ t('translations.save', 'Save') }}</UButton>
|
|
293
|
+
<UButton size="xs" color="neutral" variant="ghost" @click="editingDescription = false">{{ t('translations.cancel', 'Cancel') }}</UButton>
|
|
294
|
+
</div>
|
|
295
|
+
</template>
|
|
296
|
+
<template v-else>
|
|
297
|
+
<p v-if="keyData.description" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer hover:text-primary-500 transition-colors" @click="startEditDescription">
|
|
298
|
+
{{ keyData.description }}
|
|
299
|
+
</p>
|
|
300
|
+
<button v-else class="text-sm text-gray-400 italic hover:text-primary-500 transition-colors" @click="startEditDescription">
|
|
301
|
+
{{ t('translations.add_description', 'Add a description…') }}
|
|
302
|
+
</button>
|
|
303
|
+
</template>
|
|
304
|
+
</UCard>
|
|
305
|
+
|
|
306
|
+
<!-- Metadata -->
|
|
307
|
+
<UCard>
|
|
308
|
+
<template #header>
|
|
309
|
+
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">Info</p>
|
|
310
|
+
</template>
|
|
311
|
+
<div class="space-y-2 text-xs text-gray-500 dark:text-gray-400">
|
|
312
|
+
<div class="flex items-center gap-2">
|
|
313
|
+
<UIcon name="i-heroicons-calendar" class="shrink-0" />
|
|
314
|
+
<span>{{ formatDate(keyData.created_at) }}</span>
|
|
315
|
+
</div>
|
|
316
|
+
<div v-if="keyData.last_scanned_at" class="flex items-center gap-2">
|
|
317
|
+
<UIcon name="i-heroicons-magnifying-glass" class="shrink-0" />
|
|
318
|
+
<span>{{ formatDate(keyData.last_scanned_at) }}</span>
|
|
319
|
+
</div>
|
|
320
|
+
<div class="flex items-center gap-2">
|
|
321
|
+
<UIcon name="i-heroicons-language" class="shrink-0" />
|
|
322
|
+
<span>{{ coverageCount }} / {{ keyData.languages.length }} {{ t('translations.langs_count', 'languages') }}</span>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</UCard>
|
|
326
|
+
|
|
327
|
+
<!-- Usages -->
|
|
328
|
+
<UCard v-if="keyData.usages.length">
|
|
329
|
+
<template #header>
|
|
330
|
+
<div class="flex items-center gap-2">
|
|
331
|
+
<UIcon name="i-heroicons-code-bracket" class="text-gray-400 shrink-0" />
|
|
332
|
+
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
|
333
|
+
{{ keyData.usages.length }} {{ keyData.usages.length > 1 ? t('translations.references_plural', 'references') : t('translations.references', 'reference') }}
|
|
334
|
+
</p>
|
|
335
|
+
</div>
|
|
336
|
+
</template>
|
|
337
|
+
<div class="space-y-3">
|
|
338
|
+
<div v-for="(usage, i) in keyData.usages" :key="i" class="text-xs">
|
|
339
|
+
<p class="font-mono text-gray-600 dark:text-gray-400 truncate" :title="usage.file_path">{{ usage.file_path }}</p>
|
|
340
|
+
<div class="flex items-center gap-2 text-gray-400 mt-0.5">
|
|
341
|
+
<span>{{ t('translations.line', 'line') }} {{ usage.line_number }}</span>
|
|
342
|
+
<UBadge color="neutral" variant="soft" size="xs">{{ usage.detected_function }}</UBadge>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</UCard>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<!-- Delete confirm modal -->
|
|
352
|
+
<UModal v-model:open="showDeleteConfirm" :title="t('translations.delete_key', 'Delete key')">
|
|
353
|
+
<template #body>
|
|
354
|
+
<p class="text-gray-600 dark:text-gray-400">
|
|
355
|
+
{{ t('translations.delete_key_confirm', 'Delete') }} <strong class="font-mono">{{ keyData?.key }}</strong>?
|
|
356
|
+
{{ t('translations.delete_key_warning', 'All translations will be permanently removed.') }}
|
|
357
|
+
</p>
|
|
358
|
+
<p class="text-red-500 text-sm mt-2 font-medium">{{ t('common.irreversible', 'This action is irreversible.') }}</p>
|
|
359
|
+
</template>
|
|
360
|
+
<template #footer>
|
|
361
|
+
<div class="flex justify-end gap-3">
|
|
362
|
+
<UButton color="neutral" variant="ghost" @click="showDeleteConfirm = false">{{ t('translations.cancel', 'Cancel') }}</UButton>
|
|
363
|
+
<UButton color="error" :loading="deleting" @click="deleteKey">{{ t('translations.delete_key', 'Delete') }}</UButton>
|
|
364
|
+
</div>
|
|
365
|
+
</template>
|
|
366
|
+
</UModal>
|
|
367
|
+
</div>
|
|
368
|
+
</template>
|
|
369
|
+
|
|
370
|
+
<script lang="ts" setup>
|
|
371
|
+
const route = useRoute()
|
|
372
|
+
const projectId = computed(() => route.params.id)
|
|
373
|
+
const { t } = useT()
|
|
374
|
+
const { canManageProject, canApprove } = useAuth()
|
|
375
|
+
const { currentProject } = useProject()
|
|
376
|
+
const { findLanguage } = useLanguages()
|
|
377
|
+
|
|
378
|
+
const {
|
|
379
|
+
keyData,
|
|
380
|
+
pending,
|
|
381
|
+
refresh,
|
|
382
|
+
savingLang: saving,
|
|
383
|
+
saveTranslation: _saveTranslation,
|
|
384
|
+
settingStatus,
|
|
385
|
+
setStatus: _setStatus,
|
|
386
|
+
restoreVersion: _restoreVersion,
|
|
387
|
+
autoTranslate: _autoTranslate,
|
|
388
|
+
savingDescription,
|
|
389
|
+
updateDescription,
|
|
390
|
+
deleting,
|
|
391
|
+
deleteKey: _deleteKey,
|
|
392
|
+
} = useKeys({ id: computed(() => route.params.keyId) })
|
|
393
|
+
|
|
394
|
+
watch([keyData, pending], () => {
|
|
395
|
+
if (!pending.value && !keyData.value) {
|
|
396
|
+
throw createError({ statusCode: 404, message: 'Key not found' })
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const userCanDelete = computed(() => currentProject.value ? canManageProject(currentProject.value.id) : false)
|
|
401
|
+
const userCanApprove = computed(() => currentProject.value ? canApprove(currentProject.value.id) : false)
|
|
402
|
+
|
|
403
|
+
const coverageCount = computed(() =>
|
|
404
|
+
keyData.value?.languages.filter((l: any) => keyData.value?.translations[l.code]?.value).length || 0,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
408
|
+
function getTranslationValue(langCode: string): string {
|
|
409
|
+
return keyData.value?.translations[langCode]?.value || ''
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function getStatus(langCode: string): string {
|
|
413
|
+
return keyData.value?.translations[langCode]?.status || 'draft'
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
417
|
+
draft: 'neutral',
|
|
418
|
+
reviewed: 'info',
|
|
419
|
+
approved: 'success',
|
|
420
|
+
rejected: 'error',
|
|
421
|
+
}
|
|
422
|
+
const STATUS_LABELS = computed((): Record<string, string> => ({
|
|
423
|
+
draft: t('status.draft', 'Draft'),
|
|
424
|
+
reviewed: t('status.reviewed', 'Reviewed'),
|
|
425
|
+
approved: t('status.approved', 'Approved'),
|
|
426
|
+
rejected: t('key.status_rejected', 'Rejected'),
|
|
427
|
+
}))
|
|
428
|
+
|
|
429
|
+
function statusColor(langCode: string) {
|
|
430
|
+
return STATUS_COLORS[getStatus(langCode)] || 'neutral'
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function statusLabel(langCode: string) {
|
|
434
|
+
if (!getTranslationValue(langCode)) return t('status.missing', 'Missing')
|
|
435
|
+
return STATUS_LABELS.value[getStatus(langCode)] || getStatus(langCode)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Status actions ────────────────────────────────────────────────────────────
|
|
439
|
+
function statusActions(langCode: string) {
|
|
440
|
+
if (!getTranslationValue(langCode)) return []
|
|
441
|
+
const current = getStatus(langCode)
|
|
442
|
+
|
|
443
|
+
const all = [
|
|
444
|
+
{ label: STATUS_LABELS.value.draft, icon: 'i-heroicons-pencil', status: 'draft' },
|
|
445
|
+
{ label: STATUS_LABELS.value.reviewed, icon: 'i-heroicons-eye', status: 'reviewed' },
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
if (userCanApprove.value) {
|
|
449
|
+
all.push({ label: STATUS_LABELS.value.approved, icon: 'i-heroicons-check-circle', status: 'approved' })
|
|
450
|
+
all.push({ label: STATUS_LABELS.value.rejected, icon: 'i-heroicons-x-circle', status: 'rejected' })
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return [
|
|
454
|
+
all
|
|
455
|
+
.filter(a => a.status !== current)
|
|
456
|
+
.map(a => ({
|
|
457
|
+
label: a.label,
|
|
458
|
+
icon: a.icon,
|
|
459
|
+
color: a.status === 'rejected' ? ('error' as const) : undefined,
|
|
460
|
+
onSelect: () => setStatus(langCode, a.status),
|
|
461
|
+
})),
|
|
462
|
+
]
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function setStatus(langCode: string, status: string) {
|
|
466
|
+
await _setStatus(langCode, status)
|
|
467
|
+
refreshNuxtData('project-stats')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Source text for auto-translate ───────────────────────────────────────────
|
|
471
|
+
const sourceText = computed(() => {
|
|
472
|
+
const tr = keyData.value?.translations
|
|
473
|
+
if (!tr) return ''
|
|
474
|
+
const defaultLang = keyData.value?.languages.find((l: any) => l.is_default)
|
|
475
|
+
if (defaultLang && tr[defaultLang.code]?.value) return tr[defaultLang.code].value
|
|
476
|
+
const first = Object.values(tr).find((t: any) => t?.value)
|
|
477
|
+
return (first as any)?.value || ''
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// ── Edit translation ──────────────────────────────────────────────────────────
|
|
481
|
+
const editingLang = ref<string | null>(null)
|
|
482
|
+
const editValue = ref('')
|
|
483
|
+
const translating = ref<string | null>(null)
|
|
484
|
+
const activeTextareaWrapper = ref<HTMLElement | null>(null)
|
|
485
|
+
|
|
486
|
+
// ── Plural editor ─────────────────────────────────────────────────────────────
|
|
487
|
+
const PLURAL_SEP = ' | '
|
|
488
|
+
const pluralMode = ref(false)
|
|
489
|
+
|
|
490
|
+
function getPluralForms(langCode: string): string[] {
|
|
491
|
+
const val = getTranslationValue(langCode)
|
|
492
|
+
return val.includes(PLURAL_SEP) ? val.split(PLURAL_SEP) : [val]
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function togglePluralMode(langCode: string) {
|
|
496
|
+
if (!pluralMode.value && !editValue.value.includes(PLURAL_SEP)) {
|
|
497
|
+
// Entering plural mode: seed with current value as first form
|
|
498
|
+
editValue.value = editValue.value ? `${editValue.value} | ` : ' | '
|
|
499
|
+
}
|
|
500
|
+
pluralMode.value = !pluralMode.value
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const LINK_MODIFIERS = computed(() => [
|
|
504
|
+
{ prefix: '@:', hint: t('key.modifier_simple', 'Simple link — @:key (inserts value as-is)') },
|
|
505
|
+
{ prefix: '@.lower:', hint: t('key.modifier_lower', '@.lower:key — converts to lowercase') },
|
|
506
|
+
{ prefix: '@.upper:', hint: t('key.modifier_upper', '@.upper:key — converts to UPPERCASE') },
|
|
507
|
+
{ prefix: '@.capitalize:', hint: t('key.modifier_capitalize', '@.capitalize:key — capitalizes first letter') },
|
|
508
|
+
])
|
|
509
|
+
|
|
510
|
+
// Literal interpolation {'x'} + backslash escapes \x (v11.3+), grouped
|
|
511
|
+
const ALL_ESCAPES = computed(() => [
|
|
512
|
+
{ label: `{'@'}`, insert: `{'@'}`, hint: t('key.escape_at', `@ → {'@'} — prevents interpretation as link`) },
|
|
513
|
+
{ label: `{'{'}`, insert: `{'{'}`, hint: t('key.escape_open', `{ → {'{'} — prevents opening interpolation`) },
|
|
514
|
+
{ label: `{'}'}`, insert: `{'}'}`, hint: t('key.escape_close', `} → {'}'} — prevents closing interpolation`) },
|
|
515
|
+
{ label: `{'$'}`, insert: `{'$'}`, hint: t('key.escape_dollar', `$ → {'$'} — prevents interpretation as modifier`) },
|
|
516
|
+
{ label: `{'|'}`, insert: `{'|'}`, hint: t('key.escape_pipe', `| → {'|'} — literal pipe (≠ plural separator)`) },
|
|
517
|
+
{ label: `\\{`, insert: `\\{`, hint: t('key.escape_bs_open', `\\{ — backslash escape (vue-i18n v11.3+), alternative to {'{'}`) },
|
|
518
|
+
{ label: `\\}`, insert: `\\}`, hint: t('key.escape_bs_close', `\\} — backslash escape (vue-i18n v11.3+), alternative to {'}'}`) },
|
|
519
|
+
{ label: `\\@`, insert: `\\@`, hint: t('key.escape_bs_at', `\\@ — backslash escape (vue-i18n v11.3+), alternative to {'@'}`) },
|
|
520
|
+
{ label: `\\\\`, insert: `\\\\`, hint: t('key.escape_bs_bs', `\\\\ — literal backslash`) },
|
|
521
|
+
])
|
|
522
|
+
|
|
523
|
+
// Plural form labels
|
|
524
|
+
const PLURAL_FORM_LABELS = computed(() => [
|
|
525
|
+
`[0] ${t('key.plural_none', 'none')}`,
|
|
526
|
+
`[1] ${t('key.plural_sing', 'sing.')}`,
|
|
527
|
+
`[n] ${t('key.plural_plural', 'plural')}`,
|
|
528
|
+
'[n+1]',
|
|
529
|
+
'[n+2]',
|
|
530
|
+
])
|
|
531
|
+
|
|
532
|
+
const PARAM_REGEX = /\{([a-zA-Z_$][a-zA-Z0-9_\-$]*|\d+)\}/g
|
|
533
|
+
const detectedParams = computed(() => {
|
|
534
|
+
const params = new Set<string>()
|
|
535
|
+
const tr = keyData.value?.translations
|
|
536
|
+
if (!tr) return []
|
|
537
|
+
for (const entry of Object.values(tr)) {
|
|
538
|
+
if (!(entry as any)?.value) continue
|
|
539
|
+
for (const match of ((entry as any).value as string).matchAll(PARAM_REGEX)) {
|
|
540
|
+
params.add(match[1])
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return [...params]
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
function insertAtCursor(insertion: string) {
|
|
547
|
+
const textarea = activeTextareaWrapper.value?.querySelector('textarea')
|
|
548
|
+
if (textarea) {
|
|
549
|
+
const start = textarea.selectionStart ?? editValue.value.length
|
|
550
|
+
const end = textarea.selectionEnd ?? editValue.value.length
|
|
551
|
+
editValue.value = editValue.value.slice(0, start) + insertion + editValue.value.slice(end)
|
|
552
|
+
nextTick(() => {
|
|
553
|
+
textarea.focus()
|
|
554
|
+
textarea.selectionStart = textarea.selectionEnd = start + insertion.length
|
|
555
|
+
})
|
|
556
|
+
} else {
|
|
557
|
+
editValue.value += insertion
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function insertLinkedKey(value: string) {
|
|
562
|
+
insertAtCursor(value)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function startEdit(lang: any) {
|
|
566
|
+
editingLang.value = lang.code
|
|
567
|
+
editValue.value = getTranslationValue(lang.code)
|
|
568
|
+
pluralMode.value = editValue.value.includes(PLURAL_SEP)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function saveTranslation(langCode: string) {
|
|
572
|
+
await _saveTranslation(langCode, editValue.value)
|
|
573
|
+
editingLang.value = null
|
|
574
|
+
pluralMode.value = false
|
|
575
|
+
refreshNuxtData('project-stats')
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function autoTranslate(lang: any) {
|
|
579
|
+
if (!sourceText.value) return
|
|
580
|
+
translating.value = lang.code
|
|
581
|
+
const sourceLang = keyData.value?.languages.find((l: any) => l.is_default)?.code || 'en'
|
|
582
|
+
const text = await _autoTranslate(lang.code, sourceText.value, sourceLang)
|
|
583
|
+
if (text) { editValue.value = text; editingLang.value = lang.code }
|
|
584
|
+
translating.value = null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Restore historical version ────────────────────────────────────────────────
|
|
588
|
+
const restoring = ref<string | null>(null)
|
|
589
|
+
|
|
590
|
+
async function restoreVersion(langCode: string, entry: any) {
|
|
591
|
+
restoring.value = `${langCode}-${entry.id}`
|
|
592
|
+
await _restoreVersion(langCode, entry.new_value)
|
|
593
|
+
restoring.value = null
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── History ───────────────────────────────────────────────────────────────────
|
|
597
|
+
const expandedHistory = ref<string[]>([])
|
|
598
|
+
|
|
599
|
+
function toggleHistory(langCode: string) {
|
|
600
|
+
const idx = expandedHistory.value.indexOf(langCode)
|
|
601
|
+
if (idx >= 0) expandedHistory.value.splice(idx, 1)
|
|
602
|
+
else expandedHistory.value.push(langCode)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── Description ───────────────────────────────────────────────────────────────
|
|
606
|
+
const editingDescription = ref(false)
|
|
607
|
+
const descriptionDraft = ref('')
|
|
608
|
+
|
|
609
|
+
function startEditDescription() {
|
|
610
|
+
descriptionDraft.value = keyData.value?.description || ''
|
|
611
|
+
editingDescription.value = true
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function saveDescription() {
|
|
615
|
+
await updateDescription(descriptionDraft.value || null)
|
|
616
|
+
editingDescription.value = false
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ── Delete ────────────────────────────────────────────────────────────────────
|
|
620
|
+
const showDeleteConfirm = ref(false)
|
|
621
|
+
|
|
622
|
+
const keyActions = computed(() => {
|
|
623
|
+
if (!userCanDelete.value) return [[]]
|
|
624
|
+
return [[{
|
|
625
|
+
label: t('translations.delete_key', 'Delete key'),
|
|
626
|
+
icon: 'i-heroicons-trash',
|
|
627
|
+
color: 'error' as const,
|
|
628
|
+
onSelect: () => { showDeleteConfirm.value = true },
|
|
629
|
+
}]]
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
async function deleteKey() {
|
|
633
|
+
await _deleteKey()
|
|
634
|
+
refreshNuxtData('project-stats')
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
638
|
+
function formatDate(date: string) {
|
|
639
|
+
if (!date) return '—'
|
|
640
|
+
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(date))
|
|
641
|
+
}
|
|
642
|
+
</script>
|