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,190 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { WidgetType } from '~/types/dashboard.type'
|
|
3
|
+
import { WIDGET_REGISTRY, WIDGET_SIZE_CLASSES } from '~/consts/dashboard.const'
|
|
4
|
+
|
|
5
|
+
const { t } = useT()
|
|
6
|
+
const { currentUser } = useAuth()
|
|
7
|
+
const {
|
|
8
|
+
layout,
|
|
9
|
+
editing,
|
|
10
|
+
localLayout,
|
|
11
|
+
saving,
|
|
12
|
+
startEditing,
|
|
13
|
+
cancelEditing,
|
|
14
|
+
saveLayout,
|
|
15
|
+
onDragStart,
|
|
16
|
+
onDragOver,
|
|
17
|
+
onDragEnd,
|
|
18
|
+
removeWidget,
|
|
19
|
+
addWidget,
|
|
20
|
+
resizeWidget,
|
|
21
|
+
updateWidgetConfig,
|
|
22
|
+
} = useDashboard()
|
|
23
|
+
|
|
24
|
+
const showPicker = ref(false)
|
|
25
|
+
const configIndex = ref(-1)
|
|
26
|
+
|
|
27
|
+
const activeLayout = computed(() => editing.value ? localLayout.value : layout.value)
|
|
28
|
+
|
|
29
|
+
function sizeClass(size: string) {
|
|
30
|
+
return WIDGET_SIZE_CLASSES[size as keyof typeof WIDGET_SIZE_CLASSES] ?? 'col-span-1 row-span-1'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function widgetComponent(type: WidgetType) {
|
|
34
|
+
switch (type) {
|
|
35
|
+
case 'stat-keys':
|
|
36
|
+
case 'stat-coverage':
|
|
37
|
+
case 'stat-languages':
|
|
38
|
+
case 'stat-unused':
|
|
39
|
+
return resolveComponent('DashboardWidgetsStatWidget')
|
|
40
|
+
case 'projects':
|
|
41
|
+
return resolveComponent('DashboardWidgetsProjectsWidget')
|
|
42
|
+
case 'languages-coverage':
|
|
43
|
+
return resolveComponent('DashboardWidgetsLanguagesCoverageWidget')
|
|
44
|
+
case 'last-activity':
|
|
45
|
+
return resolveComponent('DashboardWidgetsActivityWidget')
|
|
46
|
+
case 'review-queue':
|
|
47
|
+
return resolveComponent('DashboardWidgetsReviewWidget')
|
|
48
|
+
default:
|
|
49
|
+
return 'div'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onAddWidget(widget: Parameters<typeof addWidget>[0]) {
|
|
54
|
+
addWidget(widget)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onSaveConfig(patch: { dataSource?: any; title?: string | undefined }) {
|
|
58
|
+
updateWidgetConfig(configIndex.value, patch)
|
|
59
|
+
configIndex.value = -1
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<div>
|
|
65
|
+
<div class="flex items-center justify-between mb-6">
|
|
66
|
+
<div>
|
|
67
|
+
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
68
|
+
{{ t('dashboard.hello', 'Hello') }}{{ currentUser?.name ? `, ${currentUser.name}` : '' }}
|
|
69
|
+
</h1>
|
|
70
|
+
<p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">{{ t('dashboard.title', 'Dashboard') }}</p>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="flex items-center gap-2">
|
|
74
|
+
<UButton
|
|
75
|
+
v-if="!editing"
|
|
76
|
+
variant="ghost"
|
|
77
|
+
color="neutral"
|
|
78
|
+
icon="i-heroicons-pencil-square"
|
|
79
|
+
@click="startEditing"
|
|
80
|
+
>
|
|
81
|
+
{{ t('dashboard.edit', 'Edit') }}
|
|
82
|
+
</UButton>
|
|
83
|
+
<template v-else>
|
|
84
|
+
<UButton variant="ghost" color="neutral" @click="cancelEditing">
|
|
85
|
+
{{ t('common.cancel', 'Cancel') }}
|
|
86
|
+
</UButton>
|
|
87
|
+
<UButton
|
|
88
|
+
icon="i-heroicons-plus"
|
|
89
|
+
variant="outline"
|
|
90
|
+
color="neutral"
|
|
91
|
+
@click="showPicker = true"
|
|
92
|
+
>
|
|
93
|
+
{{ t('common.add', 'Add') }}
|
|
94
|
+
</UButton>
|
|
95
|
+
<UButton :loading="saving" icon="i-heroicons-check" @click="saveLayout">
|
|
96
|
+
{{ t('dashboard.done', 'Done') }}
|
|
97
|
+
</UButton>
|
|
98
|
+
</template>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div
|
|
103
|
+
v-if="activeLayout.length === 0"
|
|
104
|
+
class="border border-dashed border-gray-300 dark:border-gray-700 rounded-xl p-16 text-center"
|
|
105
|
+
>
|
|
106
|
+
<UIcon name="i-heroicons-squares-2x2" class="text-5xl text-gray-300 dark:text-gray-600 mb-3" />
|
|
107
|
+
<p class="text-gray-400 font-medium">{{ t('dashboard.no_widgets', 'No widgets') }}</p>
|
|
108
|
+
<UButton v-if="editing" class="mt-4" icon="i-heroicons-plus" @click="showPicker = true">
|
|
109
|
+
{{ t('dashboard.add_widget', 'Add a widget') }}
|
|
110
|
+
</UButton>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div
|
|
114
|
+
v-else
|
|
115
|
+
class="grid grid-cols-4 gap-4 auto-rows-[minmax(140px,auto)]"
|
|
116
|
+
>
|
|
117
|
+
<div
|
|
118
|
+
v-for="(widget, index) in activeLayout"
|
|
119
|
+
:key="widget.id"
|
|
120
|
+
:class="[sizeClass(widget.size), 'relative', editing ? 'cursor-grab' : '']"
|
|
121
|
+
:draggable="editing"
|
|
122
|
+
@dragstart="onDragStart(index)"
|
|
123
|
+
@dragover="onDragOver($event, index)"
|
|
124
|
+
@dragend="onDragEnd"
|
|
125
|
+
>
|
|
126
|
+
<button
|
|
127
|
+
v-if="editing"
|
|
128
|
+
class="absolute -top-2 -left-2 z-10 w-5 h-5 bg-red-500 rounded-full text-white flex items-center justify-center text-xs shadow-sm hover:bg-red-600 transition-colors"
|
|
129
|
+
@click.stop="removeWidget(index)"
|
|
130
|
+
>
|
|
131
|
+
×
|
|
132
|
+
</button>
|
|
133
|
+
|
|
134
|
+
<div v-if="editing" class="absolute -top-2 right-0 z-10 flex gap-0.5">
|
|
135
|
+
<button
|
|
136
|
+
v-for="s in WIDGET_REGISTRY[widget.type].sizes"
|
|
137
|
+
:key="s"
|
|
138
|
+
class="px-1 py-0.5 text-xs rounded shadow-sm transition-colors"
|
|
139
|
+
:class="widget.size === s
|
|
140
|
+
? 'bg-primary-500 text-white'
|
|
141
|
+
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'"
|
|
142
|
+
@click.stop="resizeWidget(index, s)"
|
|
143
|
+
>
|
|
144
|
+
{{ s }}
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<button
|
|
149
|
+
v-if="editing && WIDGET_REGISTRY[widget.type].hasDataSource"
|
|
150
|
+
class="absolute bottom-1 right-1 z-10 w-6 h-6 bg-gray-600/80 dark:bg-gray-400/80 rounded-full text-white flex items-center justify-center text-xs hover:bg-gray-700 transition-colors"
|
|
151
|
+
@click.stop="configIndex = index"
|
|
152
|
+
>
|
|
153
|
+
<UIcon name="i-heroicons-cog-6-tooth" class="text-xs" />
|
|
154
|
+
</button>
|
|
155
|
+
|
|
156
|
+
<div :class="editing ? 'animate-wiggle h-full' : 'h-full'">
|
|
157
|
+
<component
|
|
158
|
+
:is="widgetComponent(widget.type)"
|
|
159
|
+
:id="widget.id"
|
|
160
|
+
:type="widget.type"
|
|
161
|
+
:size="widget.size"
|
|
162
|
+
:editing="editing"
|
|
163
|
+
:data-source="widget.dataSource"
|
|
164
|
+
:title="widget.title"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<DashboardWidgetPicker v-model="showPicker" @add="onAddWidget" />
|
|
171
|
+
|
|
172
|
+
<DashboardWidgetConfigModal
|
|
173
|
+
:open="configIndex !== -1"
|
|
174
|
+
:widget="configIndex !== -1 ? activeLayout[configIndex] : null"
|
|
175
|
+
:index="configIndex"
|
|
176
|
+
@update:open="val => { if (!val) configIndex = -1 }"
|
|
177
|
+
@save="onSaveConfig"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
</template>
|
|
181
|
+
|
|
182
|
+
<style scoped>
|
|
183
|
+
@keyframes wiggle {
|
|
184
|
+
0%, 100% { transform: rotate(-0.5deg); }
|
|
185
|
+
50% { transform: rotate(0.5deg); }
|
|
186
|
+
}
|
|
187
|
+
.animate-wiggle {
|
|
188
|
+
animation: wiggle 0.4s ease-in-out infinite;
|
|
189
|
+
}
|
|
190
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { WidgetConfig, WidgetSize } from '~/types/dashboard.type'
|
|
3
|
+
import { WIDGET_REGISTRY } from '~/consts/dashboard.const'
|
|
4
|
+
|
|
5
|
+
const { t } = useT()
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
modelValue: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
'update:modelValue': [value: boolean]
|
|
13
|
+
'add': [widget: WidgetConfig]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const open = computed({
|
|
17
|
+
get: () => props.modelValue,
|
|
18
|
+
set: (v) => emit('update:modelValue', v),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const selectedSizes = ref<Record<string, WidgetSize>>({})
|
|
22
|
+
|
|
23
|
+
function getSelectedSize(type: string): WidgetSize {
|
|
24
|
+
return selectedSizes.value[type] ?? WIDGET_REGISTRY[type as keyof typeof WIDGET_REGISTRY].defaultSize
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function addWidget(type: string) {
|
|
28
|
+
const size = getSelectedSize(type)
|
|
29
|
+
const id = Date.now().toString(36)
|
|
30
|
+
emit('add', { id, type: type as WidgetConfig['type'], size })
|
|
31
|
+
open.value = false
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<UModal v-model:open="open" :title="t('dashboard.add_widget', 'Add a widget')" :ui="{ width: 'max-w-2xl' }">
|
|
37
|
+
<template #body>
|
|
38
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-1">
|
|
39
|
+
<div
|
|
40
|
+
v-for="(config, type) in WIDGET_REGISTRY"
|
|
41
|
+
:key="type"
|
|
42
|
+
class="border border-gray-200 dark:border-gray-700 rounded-xl p-4 space-y-3 hover:border-primary-300 dark:hover:border-primary-600 transition-colors"
|
|
43
|
+
>
|
|
44
|
+
<div class="flex items-start gap-3">
|
|
45
|
+
<div class="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center shrink-0">
|
|
46
|
+
<UIcon :name="config.icon" class="text-gray-600 dark:text-gray-400" />
|
|
47
|
+
</div>
|
|
48
|
+
<div class="min-w-0">
|
|
49
|
+
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ config.label }}</p>
|
|
50
|
+
<p class="text-xs text-gray-400 mt-0.5">{{ config.description }}</p>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="flex items-center gap-1.5 flex-wrap">
|
|
55
|
+
<button
|
|
56
|
+
v-for="s in config.sizes"
|
|
57
|
+
:key="s"
|
|
58
|
+
class="px-2 py-0.5 text-xs rounded-md border transition-colors"
|
|
59
|
+
:class="getSelectedSize(type) === s
|
|
60
|
+
? 'bg-primary-500 border-primary-500 text-white'
|
|
61
|
+
: 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-primary-400'"
|
|
62
|
+
@click="selectedSizes[type] = s"
|
|
63
|
+
>
|
|
64
|
+
{{ s }}
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<UButton size="xs" variant="soft" class="w-full justify-center" @click="addWidget(type)">
|
|
69
|
+
{{ t('common.add', 'Add') }}
|
|
70
|
+
</UButton>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
</UModal>
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { PropType } from 'vue'
|
|
3
|
+
import type { WidgetSize, WidgetDataSource } from '~/types/dashboard.type'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
id: {
|
|
7
|
+
type: String,
|
|
8
|
+
required: true,
|
|
9
|
+
},
|
|
10
|
+
size: {
|
|
11
|
+
type: String as PropType<WidgetSize>,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
editing: {
|
|
15
|
+
type: Boolean,
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
dataSource: {
|
|
19
|
+
type: Object as PropType<WidgetDataSource | undefined>,
|
|
20
|
+
default: undefined,
|
|
21
|
+
},
|
|
22
|
+
title: {
|
|
23
|
+
type: String as PropType<string | undefined>,
|
|
24
|
+
default: undefined,
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const { t } = useT()
|
|
29
|
+
|
|
30
|
+
const { stats, pending, sourceLabel, hasProject } = useWidgetData(
|
|
31
|
+
props.id,
|
|
32
|
+
computed(() => props.dataSource),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const maxItems = computed(() => {
|
|
36
|
+
if (props.size === 'lg') return 10
|
|
37
|
+
if (props.size === 'wide') return 8
|
|
38
|
+
return 5
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const displayedActivity = computed(() => (stats.value?.recentActivity ?? []).slice(0, maxItems.value))
|
|
42
|
+
|
|
43
|
+
const displayTitle = computed(() => props.title || t('dashboard.recent_activity', 'Recent activity'))
|
|
44
|
+
|
|
45
|
+
function formatRelative(date: string) {
|
|
46
|
+
const diff = Date.now() - new Date(date).getTime()
|
|
47
|
+
const min = Math.floor(diff / 60000)
|
|
48
|
+
if (min < 1) return t('common.just_now', 'just now')
|
|
49
|
+
if (min < 60) return `${min}min ${t('common.ago', 'ago')}`
|
|
50
|
+
const h = Math.floor(min / 60)
|
|
51
|
+
if (h < 24) return `${h}h ${t('common.ago', 'ago')}`
|
|
52
|
+
return `${Math.floor(h / 24)}d ${t('common.ago', 'ago')}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function activityIcon(changedBy: string) {
|
|
56
|
+
if (changedBy === 'google-translate') return 'i-heroicons-sparkles'
|
|
57
|
+
if (changedBy === 'sync') return 'i-heroicons-arrow-path'
|
|
58
|
+
return 'i-heroicons-pencil'
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<UCard class="h-full overflow-hidden">
|
|
64
|
+
<template #header>
|
|
65
|
+
<div class="flex items-center gap-2">
|
|
66
|
+
<UIcon name="i-heroicons-clock" class="text-gray-400" />
|
|
67
|
+
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ displayTitle }}</span>
|
|
68
|
+
<span v-if="sourceLabel" class="ml-auto text-xs text-gray-400 dark:text-gray-500">{{ sourceLabel }}</span>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<div v-if="pending" class="space-y-2">
|
|
73
|
+
<USkeleton v-for="i in 4" :key="i" class="h-8 w-full" />
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div v-else-if="!hasProject" class="flex flex-col items-center justify-center h-full py-6 text-center">
|
|
77
|
+
<UIcon name="i-heroicons-clock" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
|
|
78
|
+
<p class="text-sm text-gray-400">{{ t('dashboard.select_project', 'Select a project') }}</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div v-else-if="!displayedActivity.length" class="flex flex-col items-center justify-center h-full py-6 text-center">
|
|
82
|
+
<UIcon name="i-heroicons-clock" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
|
|
83
|
+
<p class="text-sm text-gray-400">{{ t('dashboard.no_activity', 'No recent activity') }}</p>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div
|
|
87
|
+
v-else
|
|
88
|
+
class="overflow-y-auto"
|
|
89
|
+
:class="size === 'wide' ? 'grid grid-cols-2 gap-x-4 gap-y-2' : 'space-y-2'"
|
|
90
|
+
>
|
|
91
|
+
<div
|
|
92
|
+
v-for="item in displayedActivity"
|
|
93
|
+
:key="item.id"
|
|
94
|
+
class="flex items-start gap-2 py-1"
|
|
95
|
+
>
|
|
96
|
+
<div class="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center shrink-0 mt-0.5">
|
|
97
|
+
<UIcon :name="activityIcon(item.changed_by)" class="text-xs text-gray-500 dark:text-gray-400" />
|
|
98
|
+
</div>
|
|
99
|
+
<div class="min-w-0 flex-1">
|
|
100
|
+
<p class="text-xs font-mono text-gray-700 dark:text-gray-300 truncate">{{ item.key }}</p>
|
|
101
|
+
<div class="flex items-center gap-1.5 mt-0.5">
|
|
102
|
+
<UBadge :label="item.language_code.toUpperCase()" color="neutral" variant="soft" size="xs" />
|
|
103
|
+
<span class="text-xs text-gray-400">{{ formatRelative(item.changed_at) }}</span>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</UCard>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { PropType } from 'vue'
|
|
3
|
+
import type { WidgetSize, WidgetDataSource } from '~/types/dashboard.type'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
id: {
|
|
7
|
+
type: String,
|
|
8
|
+
required: true,
|
|
9
|
+
},
|
|
10
|
+
size: {
|
|
11
|
+
type: String as PropType<WidgetSize>,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
editing: {
|
|
15
|
+
type: Boolean,
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
dataSource: {
|
|
19
|
+
type: Object as PropType<WidgetDataSource | undefined>,
|
|
20
|
+
default: undefined,
|
|
21
|
+
},
|
|
22
|
+
title: {
|
|
23
|
+
type: String as PropType<string | undefined>,
|
|
24
|
+
default: undefined,
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const { t } = useT()
|
|
29
|
+
|
|
30
|
+
const { stats, pending, sourceLabel, hasProject } = useWidgetData(
|
|
31
|
+
props.id,
|
|
32
|
+
computed(() => props.dataSource),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const maxItems = computed(() => {
|
|
36
|
+
if (props.size === 'lg') return 6
|
|
37
|
+
if (props.size === 'wide') return 8
|
|
38
|
+
return 3
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const displayedLanguages = computed(() => (stats.value?.languages ?? []).slice(0, maxItems.value))
|
|
42
|
+
|
|
43
|
+
const displayTitle = computed(() => props.title || t('dashboard.languages_coverage', 'Language coverage'))
|
|
44
|
+
|
|
45
|
+
function coverageColor(coverage: number) {
|
|
46
|
+
if (coverage >= 90) return 'bg-green-500'
|
|
47
|
+
if (coverage >= 60) return 'bg-yellow-400'
|
|
48
|
+
return 'bg-red-400'
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<UCard class="h-full overflow-hidden">
|
|
54
|
+
<template #header>
|
|
55
|
+
<div class="flex items-center gap-2">
|
|
56
|
+
<UIcon name="i-heroicons-globe-alt" class="text-gray-400" />
|
|
57
|
+
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ displayTitle }}</span>
|
|
58
|
+
<span v-if="sourceLabel" class="ml-auto text-xs text-gray-400 dark:text-gray-500">{{ sourceLabel }}</span>
|
|
59
|
+
</div>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
62
|
+
<div v-if="pending" class="space-y-3">
|
|
63
|
+
<USkeleton v-for="i in 3" :key="i" class="h-8 w-full" />
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div v-else-if="!hasProject" class="flex flex-col items-center justify-center h-full py-6 text-center">
|
|
67
|
+
<UIcon name="i-heroicons-globe-alt" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
|
|
68
|
+
<p class="text-sm text-gray-400">{{ t('dashboard.select_project', 'Select a project') }}</p>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div v-else-if="!displayedLanguages.length" class="flex flex-col items-center justify-center h-full py-6 text-center">
|
|
72
|
+
<UIcon name="i-heroicons-globe-alt" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
|
|
73
|
+
<p class="text-sm text-gray-400">{{ t('dashboard.no_languages', 'No language configured') }}</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div
|
|
77
|
+
v-else
|
|
78
|
+
class="overflow-y-auto space-y-3"
|
|
79
|
+
:class="size === 'wide' ? 'grid grid-cols-2 gap-3 space-y-0' : ''"
|
|
80
|
+
>
|
|
81
|
+
<div v-for="lang in displayedLanguages" :key="lang.code" class="space-y-1">
|
|
82
|
+
<div class="flex items-center justify-between text-xs">
|
|
83
|
+
<span class="font-medium text-gray-700 dark:text-gray-300">
|
|
84
|
+
{{ lang.name }}
|
|
85
|
+
<span class="text-gray-400 ml-1 font-mono">{{ lang.code }}</span>
|
|
86
|
+
</span>
|
|
87
|
+
<span class="font-semibold" :class="lang.coverage >= 90 ? 'text-green-600' : lang.coverage >= 60 ? 'text-yellow-500' : 'text-red-500'">
|
|
88
|
+
{{ lang.coverage }}%
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="w-full bg-gray-100 dark:bg-gray-800 rounded-full h-1.5">
|
|
92
|
+
<div
|
|
93
|
+
class="h-1.5 rounded-full transition-all"
|
|
94
|
+
:class="coverageColor(lang.coverage)"
|
|
95
|
+
:style="{ width: `${lang.coverage}%` }"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<p v-if="size !== 'sm'" class="text-xs text-gray-400">
|
|
99
|
+
{{ lang.translated }} / {{ lang.total }} · {{ lang.missing }} {{ t('dashboard.missing', 'missing') }}
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</UCard>
|
|
104
|
+
</template>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { WidgetSize } from '~/types/dashboard.type'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
size: WidgetSize
|
|
6
|
+
editing: boolean
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { t } = useT()
|
|
10
|
+
const { visibleProjects, pending } = useProject()
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
|
|
13
|
+
const maxItems = computed(() => {
|
|
14
|
+
if (props.size === 'lg') return 6
|
|
15
|
+
if (props.size === 'wide') return 4
|
|
16
|
+
return 3
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const displayedProjects = computed(() => visibleProjects.value.slice(0, maxItems.value))
|
|
20
|
+
|
|
21
|
+
function navigate(id: number) {
|
|
22
|
+
if (props.editing) return
|
|
23
|
+
router.push(`/projects/${id}`)
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<UCard class="h-full overflow-hidden">
|
|
29
|
+
<template #header>
|
|
30
|
+
<div class="flex items-center gap-2">
|
|
31
|
+
<UIcon name="i-heroicons-rectangle-stack" class="text-gray-400" />
|
|
32
|
+
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('nav.projects', 'Projects') }}</span>
|
|
33
|
+
<UBadge v-if="visibleProjects.length" :label="String(visibleProjects.length)" color="neutral" variant="soft" size="xs" class="ml-auto" />
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<div v-if="pending" class="space-y-2">
|
|
38
|
+
<USkeleton v-for="i in 3" :key="i" class="h-10 w-full" />
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div v-else-if="!displayedProjects.length" class="flex flex-col items-center justify-center h-full py-6 text-center">
|
|
42
|
+
<UIcon name="i-heroicons-folder-open" class="text-3xl text-gray-300 dark:text-gray-600 mb-2" />
|
|
43
|
+
<p class="text-sm text-gray-400">{{ t('projects.none', 'No project') }}</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
v-else
|
|
48
|
+
class="overflow-y-auto"
|
|
49
|
+
:class="size === 'wide' ? 'grid grid-cols-2 gap-2' : 'space-y-2'"
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
v-for="project in displayedProjects"
|
|
53
|
+
:key="project.id"
|
|
54
|
+
class="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
|
|
55
|
+
:class="{ 'cursor-default': editing }"
|
|
56
|
+
@click="navigate(project.id)"
|
|
57
|
+
>
|
|
58
|
+
<div
|
|
59
|
+
class="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
|
|
60
|
+
:class="`bg-${project.color || 'primary'}-100 dark:bg-${project.color || 'primary'}-900/30`"
|
|
61
|
+
>
|
|
62
|
+
<UIcon
|
|
63
|
+
name="i-heroicons-folder"
|
|
64
|
+
class="text-sm"
|
|
65
|
+
:class="`text-${project.color || 'primary'}-600`"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="min-w-0 flex-1">
|
|
69
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ project.name }}</p>
|
|
70
|
+
<p class="text-xs text-gray-400">
|
|
71
|
+
{{ project.key_count ?? 0 }} {{ t('translations.keys_count', 'keys') }} · {{ project.language_count ?? 0 }} {{ t('translations.langs_count', 'languages') }}
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</UCard>
|
|
77
|
+
</template>
|