@motor-cms/ui-admin 1.3.0 → 1.4.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/app/components/dashboard/DashboardActivity.vue +71 -0
- package/app/components/dashboard/DashboardActivityItem.vue +96 -0
- package/app/components/dashboard/DashboardAnnouncementModal.vue +279 -0
- package/app/components/dashboard/DashboardAnnouncements.vue +88 -0
- package/app/components/dashboard/DashboardPublishingQueue.vue +47 -0
- package/app/components/dashboard/DashboardQuickActions.vue +44 -0
- package/app/components/dashboard/DashboardStats.vue +63 -0
- package/app/composables/useDashboardData.ts +168 -0
- package/app/lang/de/motor-admin/dashboard.json +81 -0
- package/app/lang/en/motor-admin/dashboard.json +81 -0
- package/app/pages/index.vue +100 -22
- package/app/pages/search.vue +7 -3
- package/package.json +2 -2
- package/app/pages/dashboard.vue +0 -5
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ActivityItem } from '../../composables/useDashboardData'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
items: ActivityItem[]
|
|
6
|
+
loading: boolean
|
|
7
|
+
loadingMore: boolean
|
|
8
|
+
hasMore: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
loadMore: []
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
const scrollContainer = ref<HTMLElement | null>(null)
|
|
17
|
+
const sentinel = ref<HTMLElement | null>(null)
|
|
18
|
+
let observer: IntersectionObserver | null = null
|
|
19
|
+
|
|
20
|
+
function setupObserver() {
|
|
21
|
+
observer?.disconnect()
|
|
22
|
+
if (!scrollContainer.value || !sentinel.value) return
|
|
23
|
+
|
|
24
|
+
observer = new IntersectionObserver(
|
|
25
|
+
([entry]) => {
|
|
26
|
+
if (entry?.isIntersecting && props.hasMore && !props.loadingMore) {
|
|
27
|
+
emit('loadMore')
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{ root: scrollContainer.value, threshold: 0 },
|
|
31
|
+
)
|
|
32
|
+
observer.observe(sentinel.value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onMounted(setupObserver)
|
|
36
|
+
watch([scrollContainer, sentinel], setupObserver)
|
|
37
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<UPageCard :ui="{ root: 'border-0 shadow-none' }">
|
|
42
|
+
<template #header>
|
|
43
|
+
<div class="flex items-center justify-between w-full">
|
|
44
|
+
<span class="text-sm font-heading font-semibold text-highlighted">{{ t('motor-admin.dashboard.activity.title') }}</span>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
47
|
+
<div v-if="loading" class="flex items-center justify-center py-12 text-muted">
|
|
48
|
+
<UIcon name="i-lucide-loader-2" class="size-5 animate-spin" />
|
|
49
|
+
</div>
|
|
50
|
+
<div v-else-if="items.length === 0" class="flex flex-col items-center justify-center py-12 text-muted">
|
|
51
|
+
<UIcon name="i-lucide-activity" class="size-10 mb-3" />
|
|
52
|
+
<p class="text-sm">{{ t('motor-admin.dashboard.activity.empty') }}</p>
|
|
53
|
+
</div>
|
|
54
|
+
<div v-else class="relative -mx-4 -my-4 sm:-mx-6 sm:-my-6">
|
|
55
|
+
<div
|
|
56
|
+
ref="scrollContainer"
|
|
57
|
+
class="max-h-[420px] overflow-y-auto"
|
|
58
|
+
>
|
|
59
|
+
<DashboardActivityItem
|
|
60
|
+
v-for="item in items"
|
|
61
|
+
:key="item.id"
|
|
62
|
+
:item="item"
|
|
63
|
+
/>
|
|
64
|
+
<div ref="sentinel" class="h-px" />
|
|
65
|
+
<div v-if="loadingMore" class="flex items-center justify-center py-3 text-muted">
|
|
66
|
+
<UIcon name="i-lucide-loader-2" class="size-4 animate-spin" />
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</UPageCard>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { formatTimeAgo } from '@vueuse/core'
|
|
3
|
+
import type { ActivityItem } from '../../composables/useDashboardData'
|
|
4
|
+
|
|
5
|
+
const { t } = useI18n()
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
item: ActivityItem
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const typeLabels: Record<string, string> = {
|
|
12
|
+
BuilderPage: 'page',
|
|
13
|
+
Navigation: 'navigation',
|
|
14
|
+
NavigationItem: 'navigation',
|
|
15
|
+
File: 'media',
|
|
16
|
+
CustomContentType: 'content_type',
|
|
17
|
+
CustomContentField: 'content_type',
|
|
18
|
+
Clickpath: 'assistant',
|
|
19
|
+
ClickpathStep: 'assistant',
|
|
20
|
+
Topic: 'scoring',
|
|
21
|
+
PublishingTime: 'scheduled',
|
|
22
|
+
Approval: 'approval',
|
|
23
|
+
SeoRedirect: 'seo',
|
|
24
|
+
SearchConfig: 'search',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const typeIcons: Record<string, string> = {
|
|
28
|
+
BuilderPage: 'i-lucide-file-text',
|
|
29
|
+
Navigation: 'i-lucide-menu',
|
|
30
|
+
NavigationItem: 'i-lucide-menu',
|
|
31
|
+
File: 'i-lucide-image',
|
|
32
|
+
CustomContentType: 'i-lucide-layout-grid',
|
|
33
|
+
CustomContentField: 'i-lucide-layout-grid',
|
|
34
|
+
Clickpath: 'i-lucide-mouse-pointer-click',
|
|
35
|
+
ClickpathStep: 'i-lucide-mouse-pointer-click',
|
|
36
|
+
Topic: 'i-lucide-bar-chart-2',
|
|
37
|
+
PublishingTime: 'i-lucide-clock',
|
|
38
|
+
Approval: 'i-lucide-check-circle',
|
|
39
|
+
SeoRedirect: 'i-lucide-link',
|
|
40
|
+
SearchConfig: 'i-lucide-search',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function typeLabel(subjectType: string): string {
|
|
44
|
+
const key = typeLabels[subjectType]
|
|
45
|
+
return key ? t(`motor-admin.dashboard.activity.types.${key}`) : subjectType
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isDeleted = computed(() => props.item.description === 'deleted')
|
|
49
|
+
|
|
50
|
+
const config = computed(() => {
|
|
51
|
+
const icon = isDeleted.value
|
|
52
|
+
? 'i-lucide-trash-2'
|
|
53
|
+
: typeIcons[props.item.subject_type] ?? 'i-lucide-activity'
|
|
54
|
+
return {
|
|
55
|
+
icon,
|
|
56
|
+
label: typeLabel(props.item.subject_type),
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const verb = computed(() => {
|
|
61
|
+
const key = props.item.description
|
|
62
|
+
const translated = t(`motor-admin.dashboard.activity.verbs.${key}`)
|
|
63
|
+
return translated.startsWith('motor-admin.') ? key : translated
|
|
64
|
+
})
|
|
65
|
+
const timestamp = computed(() => new Date(props.item.created_at))
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div class="flex items-start gap-3 px-4 py-3 last:pb-0 hover:bg-elevated/50 transition-colors">
|
|
70
|
+
<div
|
|
71
|
+
class="flex items-center justify-center size-7 rounded-md flex-shrink-0 mt-0.5"
|
|
72
|
+
:class="isDeleted ? 'bg-error/10' : 'bg-muted/50'"
|
|
73
|
+
>
|
|
74
|
+
<UIcon
|
|
75
|
+
:name="config.icon"
|
|
76
|
+
class="size-3.5"
|
|
77
|
+
:class="isDeleted ? 'text-error' : 'text-dimmed'"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="flex-1 min-w-0">
|
|
81
|
+
<div class="text-sm text-default">
|
|
82
|
+
<strong>{{ item.subject_name ?? t('motor-admin.dashboard.activity.unknown') }}</strong> {{ verb }}
|
|
83
|
+
</div>
|
|
84
|
+
<div class="text-xs text-dimmed mt-0.5">
|
|
85
|
+
{{ item.causer_name ?? t('motor-admin.dashboard.activity.system') }} · {{ formatTimeAgo(timestamp) }}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<UBadge
|
|
89
|
+
:color="isDeleted ? 'error' : 'neutral'"
|
|
90
|
+
variant="subtle"
|
|
91
|
+
size="xs"
|
|
92
|
+
>
|
|
93
|
+
{{ config.label }}
|
|
94
|
+
</UBadge>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
open: boolean
|
|
4
|
+
}>()
|
|
5
|
+
|
|
6
|
+
const emit = defineEmits<{
|
|
7
|
+
'update:open': [value: boolean]
|
|
8
|
+
created: []
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
const client = useSanctumClient()
|
|
13
|
+
const { can } = usePermissions()
|
|
14
|
+
const saving = ref(false)
|
|
15
|
+
|
|
16
|
+
const canWriteAnnouncements = computed(() => can('dashboard-announcements.write'))
|
|
17
|
+
|
|
18
|
+
const form = reactive({
|
|
19
|
+
title: '',
|
|
20
|
+
body: '',
|
|
21
|
+
type: 'info',
|
|
22
|
+
audience: 'self',
|
|
23
|
+
target_user_ids: [] as number[],
|
|
24
|
+
linkable_type: '',
|
|
25
|
+
linkable_id: undefined as number | undefined,
|
|
26
|
+
starts_at: undefined as string | undefined,
|
|
27
|
+
expires_at: undefined as string | undefined,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const typeOptions = computed(() => [
|
|
31
|
+
{ label: t('motor-admin.dashboard.announcements.type_info'), value: 'info' },
|
|
32
|
+
{ label: t('motor-admin.dashboard.announcements.type_warning'), value: 'warning' },
|
|
33
|
+
{ label: t('motor-admin.dashboard.announcements.type_error'), value: 'error' },
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
const allAudienceOptions = computed(() => [
|
|
37
|
+
{ label: t('motor-admin.dashboard.announcements.audience_self'), value: 'self' },
|
|
38
|
+
{ label: t('motor-admin.dashboard.announcements.audience_users'), value: 'users' },
|
|
39
|
+
{ label: t('motor-admin.dashboard.announcements.audience_client'), value: 'client' },
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
const audienceOptions = computed(() =>
|
|
43
|
+
canWriteAnnouncements.value
|
|
44
|
+
? allAudienceOptions.value
|
|
45
|
+
: allAudienceOptions.value.filter(o => o.value === 'self')
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const linkableTypeOptions = computed(() => [
|
|
49
|
+
{ label: t('motor-admin.dashboard.announcements.linkable_page'), value: 'Motor\\Builder\\Models\\BuilderPage' },
|
|
50
|
+
{ label: t('motor-admin.dashboard.announcements.linkable_navigation'), value: 'Motor\\Builder\\Models\\Navigation' },
|
|
51
|
+
{ label: t('motor-admin.dashboard.announcements.linkable_file'), value: 'Motor\\Media\\Models\\File' },
|
|
52
|
+
{ label: t('motor-admin.dashboard.announcements.linkable_content_type'), value: 'Motor\\ContentType\\Models\\CustomContentType' },
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
const userOptions = ref<Array<{ label: string; value: number }>>([])
|
|
56
|
+
const usersLoading = ref(false)
|
|
57
|
+
const usersFetched = ref(false)
|
|
58
|
+
|
|
59
|
+
watch(() => form.audience, async (audience) => {
|
|
60
|
+
if (audience === 'users' && !usersFetched.value) {
|
|
61
|
+
usersLoading.value = true
|
|
62
|
+
try {
|
|
63
|
+
const response = await client<{ data: Array<{ id: number; name: string }> }>('/api/v2/users?per_page=200')
|
|
64
|
+
userOptions.value = response.data.map(u => ({
|
|
65
|
+
label: u.name,
|
|
66
|
+
value: u.id,
|
|
67
|
+
}))
|
|
68
|
+
usersFetched.value = true
|
|
69
|
+
} finally {
|
|
70
|
+
usersLoading.value = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const linkableOptions = ref<Array<{ label: string; value: number }>>([])
|
|
76
|
+
const linkableLoading = ref(false)
|
|
77
|
+
const linkableSearchTerm = ref('')
|
|
78
|
+
let linkableSearchTimeout: ReturnType<typeof setTimeout>
|
|
79
|
+
|
|
80
|
+
const linkableEndpoints: Record<string, { endpoint: string; labelFn: (item: Record<string, any>) => string }> = {
|
|
81
|
+
'Motor\\Builder\\Models\\BuilderPage': {
|
|
82
|
+
endpoint: '/api/v2/builder-pages',
|
|
83
|
+
labelFn: item => item.name ?? `#${item.id}`,
|
|
84
|
+
},
|
|
85
|
+
'Motor\\Builder\\Models\\Navigation': {
|
|
86
|
+
endpoint: '/api/v2/navigation-items',
|
|
87
|
+
labelFn: item => item.full_slug ? `${item.name} (${item.full_slug})` : item.name ?? `#${item.id}`,
|
|
88
|
+
},
|
|
89
|
+
'Motor\\Media\\Models\\File': {
|
|
90
|
+
endpoint: '/api/v2/files',
|
|
91
|
+
labelFn: item => item.description || item.file?.file_name || `#${item.id}`,
|
|
92
|
+
},
|
|
93
|
+
'Motor\\ContentType\\Models\\CustomContentType': {
|
|
94
|
+
endpoint: '/api/v2/custom-content-types',
|
|
95
|
+
labelFn: item => item.name ?? `#${item.id}`,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchLinkableOptions(query: string) {
|
|
100
|
+
const config = linkableEndpoints[form.linkable_type]
|
|
101
|
+
if (!config) return
|
|
102
|
+
|
|
103
|
+
linkableLoading.value = true
|
|
104
|
+
try {
|
|
105
|
+
const params = new URLSearchParams({ per_page: '20' })
|
|
106
|
+
if (query) {
|
|
107
|
+
params.set('search', query)
|
|
108
|
+
}
|
|
109
|
+
const response = await client<{ data: Array<Record<string, any>> }>(`${config.endpoint}?${params}`)
|
|
110
|
+
linkableOptions.value = response.data.map(item => ({
|
|
111
|
+
label: config.labelFn(item),
|
|
112
|
+
value: item.id,
|
|
113
|
+
}))
|
|
114
|
+
} catch {
|
|
115
|
+
linkableOptions.value = []
|
|
116
|
+
} finally {
|
|
117
|
+
linkableLoading.value = false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
watch(() => form.linkable_type, (type) => {
|
|
122
|
+
form.linkable_id = undefined
|
|
123
|
+
linkableOptions.value = []
|
|
124
|
+
linkableSearchTerm.value = ''
|
|
125
|
+
if (!type) return
|
|
126
|
+
fetchLinkableOptions('')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
watch(linkableSearchTerm, (term) => {
|
|
130
|
+
clearTimeout(linkableSearchTimeout)
|
|
131
|
+
linkableSearchTimeout = setTimeout(() => fetchLinkableOptions(term), 300)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
async function handleSubmit() {
|
|
135
|
+
saving.value = true
|
|
136
|
+
try {
|
|
137
|
+
const data: Record<string, unknown> = {
|
|
138
|
+
title: form.title,
|
|
139
|
+
body: form.body || null,
|
|
140
|
+
type: form.type,
|
|
141
|
+
audience: form.audience,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (form.audience === 'users') {
|
|
145
|
+
data.target_user_ids = form.target_user_ids
|
|
146
|
+
}
|
|
147
|
+
if (form.linkable_type && form.linkable_id) {
|
|
148
|
+
data.linkable_type = form.linkable_type
|
|
149
|
+
data.linkable_id = form.linkable_id
|
|
150
|
+
}
|
|
151
|
+
if (form.starts_at) {
|
|
152
|
+
data.starts_at = form.starts_at
|
|
153
|
+
}
|
|
154
|
+
if (form.expires_at) {
|
|
155
|
+
data.expires_at = form.expires_at
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await client('/api/v2/dashboard/announcements', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
body: data,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
Object.assign(form, {
|
|
164
|
+
title: '',
|
|
165
|
+
body: '',
|
|
166
|
+
type: 'info',
|
|
167
|
+
audience: 'self',
|
|
168
|
+
target_user_ids: [],
|
|
169
|
+
linkable_type: '',
|
|
170
|
+
linkable_id: null,
|
|
171
|
+
starts_at: null,
|
|
172
|
+
expires_at: null,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
emit('created')
|
|
176
|
+
emit('update:open', false)
|
|
177
|
+
} finally {
|
|
178
|
+
saving.value = false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
<template>
|
|
184
|
+
<UModal
|
|
185
|
+
:open="open"
|
|
186
|
+
class="sm:max-w-xl"
|
|
187
|
+
@update:open="emit('update:open', $event)"
|
|
188
|
+
>
|
|
189
|
+
<template #header>
|
|
190
|
+
<span class="font-heading font-semibold">{{ t('motor-admin.dashboard.announcements.modal_title') }}</span>
|
|
191
|
+
</template>
|
|
192
|
+
|
|
193
|
+
<template #body>
|
|
194
|
+
<div class="flex flex-col gap-5">
|
|
195
|
+
<UFormField :label="t('motor-admin.dashboard.announcements.field_title')" required>
|
|
196
|
+
<UInput v-model="form.title" class="w-full" :placeholder="t('motor-admin.dashboard.announcements.field_title_placeholder')" />
|
|
197
|
+
</UFormField>
|
|
198
|
+
|
|
199
|
+
<UFormField :label="t('motor-admin.dashboard.announcements.field_body')">
|
|
200
|
+
<UTextarea v-model="form.body" class="w-full" :placeholder="t('motor-admin.dashboard.announcements.field_body_placeholder')" :rows="3" />
|
|
201
|
+
</UFormField>
|
|
202
|
+
|
|
203
|
+
<div class="grid grid-cols-2 gap-4">
|
|
204
|
+
<UFormField :label="t('motor-admin.dashboard.announcements.field_type')">
|
|
205
|
+
<USelectMenu v-model="form.type" :items="typeOptions" value-key="value" class="w-full" />
|
|
206
|
+
</UFormField>
|
|
207
|
+
|
|
208
|
+
<UFormField :label="t('motor-admin.dashboard.announcements.field_audience')">
|
|
209
|
+
<USelectMenu v-model="form.audience" :items="audienceOptions" value-key="value" class="w-full" />
|
|
210
|
+
</UFormField>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<UFormField v-if="form.audience === 'users'" :label="t('motor-admin.dashboard.announcements.field_users')">
|
|
214
|
+
<USelectMenu
|
|
215
|
+
v-model="form.target_user_ids"
|
|
216
|
+
:items="userOptions"
|
|
217
|
+
value-key="value"
|
|
218
|
+
multiple
|
|
219
|
+
:loading="usersLoading"
|
|
220
|
+
:placeholder="t('motor-admin.dashboard.announcements.field_users_placeholder')"
|
|
221
|
+
class="w-full"
|
|
222
|
+
/>
|
|
223
|
+
</UFormField>
|
|
224
|
+
|
|
225
|
+
<UFormField :label="t('motor-admin.dashboard.announcements.field_link')">
|
|
226
|
+
<USelectMenu
|
|
227
|
+
v-model="form.linkable_type"
|
|
228
|
+
:items="linkableTypeOptions"
|
|
229
|
+
value-key="value"
|
|
230
|
+
:placeholder="t('motor-admin.dashboard.announcements.field_link_type_placeholder')"
|
|
231
|
+
class="w-full"
|
|
232
|
+
/>
|
|
233
|
+
</UFormField>
|
|
234
|
+
|
|
235
|
+
<UFormField v-if="form.linkable_type" :label="t('motor-admin.dashboard.announcements.field_link_item')">
|
|
236
|
+
<USelectMenu
|
|
237
|
+
v-model="form.linkable_id"
|
|
238
|
+
v-model:search-term="linkableSearchTerm"
|
|
239
|
+
:items="linkableOptions"
|
|
240
|
+
value-key="value"
|
|
241
|
+
ignore-filter
|
|
242
|
+
:loading="linkableLoading"
|
|
243
|
+
:placeholder="t('motor-admin.dashboard.announcements.field_link_item_placeholder')"
|
|
244
|
+
class="w-full"
|
|
245
|
+
/>
|
|
246
|
+
</UFormField>
|
|
247
|
+
|
|
248
|
+
<div class="grid grid-cols-2 gap-4">
|
|
249
|
+
<UFormField :label="t('motor-admin.dashboard.announcements.field_starts_at')">
|
|
250
|
+
<UInput v-model="form.starts_at" type="datetime-local" class="w-full" />
|
|
251
|
+
</UFormField>
|
|
252
|
+
<UFormField :label="t('motor-admin.dashboard.announcements.field_expires_at')">
|
|
253
|
+
<UInput v-model="form.expires_at" type="datetime-local" class="w-full" />
|
|
254
|
+
</UFormField>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</template>
|
|
258
|
+
|
|
259
|
+
<template #footer>
|
|
260
|
+
<div class="flex justify-end gap-2">
|
|
261
|
+
<UButton
|
|
262
|
+
color="neutral"
|
|
263
|
+
variant="outline"
|
|
264
|
+
@click="emit('update:open', false)"
|
|
265
|
+
>
|
|
266
|
+
{{ t('motor-admin.dashboard.announcements.cancel') }}
|
|
267
|
+
</UButton>
|
|
268
|
+
<UButton
|
|
269
|
+
color="primary"
|
|
270
|
+
:loading="saving"
|
|
271
|
+
:disabled="!form.title"
|
|
272
|
+
@click="handleSubmit"
|
|
273
|
+
>
|
|
274
|
+
{{ t('motor-admin.dashboard.announcements.create') }}
|
|
275
|
+
</UButton>
|
|
276
|
+
</div>
|
|
277
|
+
</template>
|
|
278
|
+
</UModal>
|
|
279
|
+
</template>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { formatTimeAgo } from '@vueuse/core'
|
|
3
|
+
import type { AnnouncementItem } from '../../composables/useDashboardData'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
items: AnnouncementItem[]
|
|
7
|
+
loading: boolean
|
|
8
|
+
canCreate?: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
dismiss: [id: number]
|
|
13
|
+
create: []
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
|
|
18
|
+
const borderColors: Record<string, string> = {
|
|
19
|
+
info: 'border-l-info',
|
|
20
|
+
warning: 'border-l-warning',
|
|
21
|
+
error: 'border-l-error',
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<UPageCard :ui="{ root: 'border-0 shadow-none' }">
|
|
27
|
+
<template #header>
|
|
28
|
+
<div class="flex items-center justify-between w-full">
|
|
29
|
+
<div class="flex items-center gap-2">
|
|
30
|
+
<UIcon name="i-lucide-megaphone" class="size-4 text-primary" />
|
|
31
|
+
<span class="text-sm font-heading font-semibold text-highlighted">{{ t('motor-admin.dashboard.announcements.title') }}</span>
|
|
32
|
+
<UBadge
|
|
33
|
+
v-if="items.length > 0"
|
|
34
|
+
color="primary"
|
|
35
|
+
variant="subtle"
|
|
36
|
+
size="md"
|
|
37
|
+
>
|
|
38
|
+
{{ items.length }}
|
|
39
|
+
</UBadge>
|
|
40
|
+
</div>
|
|
41
|
+
<UButton
|
|
42
|
+
v-if="canCreate"
|
|
43
|
+
icon="i-lucide-plus"
|
|
44
|
+
size="xs"
|
|
45
|
+
variant="ghost"
|
|
46
|
+
color="neutral"
|
|
47
|
+
@click="emit('create')"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
<div v-if="loading" class="flex items-center justify-center py-6 text-muted">
|
|
52
|
+
<UIcon name="i-lucide-loader-2" class="size-5 animate-spin" />
|
|
53
|
+
</div>
|
|
54
|
+
<div v-else-if="items.length === 0" class="text-sm text-muted py-4 text-center">
|
|
55
|
+
{{ t('motor-admin.dashboard.announcements.empty') }}
|
|
56
|
+
</div>
|
|
57
|
+
<div v-else class="flex flex-col gap-2">
|
|
58
|
+
<div
|
|
59
|
+
v-for="item in items"
|
|
60
|
+
:key="item.id"
|
|
61
|
+
class="p-3 rounded-lg bg-muted border-l-3"
|
|
62
|
+
:class="borderColors[item.type] ?? 'border-l-info'"
|
|
63
|
+
>
|
|
64
|
+
<div class="flex items-start justify-between gap-2">
|
|
65
|
+
<div class="text-sm font-medium text-default">{{ item.title }}</div>
|
|
66
|
+
<button
|
|
67
|
+
class="flex-shrink-0 text-dimmed hover:text-default transition-colors"
|
|
68
|
+
@click="emit('dismiss', item.id)"
|
|
69
|
+
>
|
|
70
|
+
<UIcon name="i-lucide-x" class="size-3.5" />
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
<p v-if="item.body" class="text-xs text-muted mt-1">{{ item.body }}</p>
|
|
74
|
+
<NuxtLink
|
|
75
|
+
v-if="item.linkable_url"
|
|
76
|
+
:to="item.linkable_url"
|
|
77
|
+
class="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-2"
|
|
78
|
+
>
|
|
79
|
+
<UIcon name="i-lucide-external-link" class="size-3" />
|
|
80
|
+
{{ item.linkable_name }}
|
|
81
|
+
</NuxtLink>
|
|
82
|
+
<div class="text-xs text-dimmed mt-2">
|
|
83
|
+
{{ item.created_by_name }} · {{ formatTimeAgo(new Date(item.created_at)) }}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</UPageCard>
|
|
88
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { PublishingQueueItem } from '../../composables/useDashboardData'
|
|
3
|
+
|
|
4
|
+
const { t, locale } = useI18n()
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
items: PublishingQueueItem[]
|
|
8
|
+
loading: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
function formatDate(isoString: string): string {
|
|
12
|
+
return new Date(isoString).toLocaleDateString(locale.value, {
|
|
13
|
+
day: 'numeric',
|
|
14
|
+
month: 'long',
|
|
15
|
+
hour: '2-digit',
|
|
16
|
+
minute: '2-digit',
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<UPageCard :ui="{ root: 'border-0 shadow-none' }">
|
|
23
|
+
<template #header>
|
|
24
|
+
<div class="flex items-center gap-2">
|
|
25
|
+
<UIcon name="i-lucide-clock" class="size-4 text-primary" />
|
|
26
|
+
<span class="text-sm font-heading font-semibold text-highlighted">{{ t('motor-admin.dashboard.publishing_queue.title') }}</span>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
<div v-if="loading" class="flex items-center justify-center py-6 text-muted">
|
|
30
|
+
<UIcon name="i-lucide-loader-2" class="size-5 animate-spin" />
|
|
31
|
+
</div>
|
|
32
|
+
<div v-else-if="items.length === 0" class="text-sm text-muted py-4 text-center">
|
|
33
|
+
{{ t('motor-admin.dashboard.publishing_queue.empty') }}
|
|
34
|
+
</div>
|
|
35
|
+
<div v-else class="flex flex-col gap-2">
|
|
36
|
+
<NuxtLink
|
|
37
|
+
v-for="item in items"
|
|
38
|
+
:key="item.id"
|
|
39
|
+
:to="`/motor-builder/builder-pages/${item.publishable_id}/edit`"
|
|
40
|
+
class="p-3 rounded-lg bg-muted hover:bg-accented transition-colors"
|
|
41
|
+
>
|
|
42
|
+
<div class="text-sm font-medium text-default">{{ item.name }}</div>
|
|
43
|
+
<div class="text-xs text-dimmed mt-1">{{ formatDate(item.to_be_published_at) }}</div>
|
|
44
|
+
</NuxtLink>
|
|
45
|
+
</div>
|
|
46
|
+
</UPageCard>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { t } = useI18n()
|
|
3
|
+
|
|
4
|
+
const emit = defineEmits<{
|
|
5
|
+
'create-page': []
|
|
6
|
+
'upload-media': []
|
|
7
|
+
}>()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<UPageCard>
|
|
12
|
+
<template #header>
|
|
13
|
+
<span class="text-sm font-heading font-semibold text-highlighted">{{ t('motor-admin.dashboard.quick_actions.title') }}</span>
|
|
14
|
+
</template>
|
|
15
|
+
<div class="flex flex-col gap-2">
|
|
16
|
+
<UButton
|
|
17
|
+
icon="i-lucide-plus"
|
|
18
|
+
color="primary"
|
|
19
|
+
block
|
|
20
|
+
@click="emit('create-page')"
|
|
21
|
+
>
|
|
22
|
+
{{ t('motor-admin.dashboard.quick_actions.new_page') }}
|
|
23
|
+
</UButton>
|
|
24
|
+
<UButton
|
|
25
|
+
icon="i-lucide-upload"
|
|
26
|
+
color="neutral"
|
|
27
|
+
variant="outline"
|
|
28
|
+
block
|
|
29
|
+
@click="emit('upload-media')"
|
|
30
|
+
>
|
|
31
|
+
{{ t('motor-admin.dashboard.quick_actions.upload_media') }}
|
|
32
|
+
</UButton>
|
|
33
|
+
<UButton
|
|
34
|
+
icon="i-lucide-menu"
|
|
35
|
+
color="neutral"
|
|
36
|
+
variant="outline"
|
|
37
|
+
block
|
|
38
|
+
to="/motor-builder/navigation-trees"
|
|
39
|
+
>
|
|
40
|
+
{{ t('motor-admin.dashboard.quick_actions.navigation') }}
|
|
41
|
+
</UButton>
|
|
42
|
+
</div>
|
|
43
|
+
</UPageCard>
|
|
44
|
+
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DashboardStats } from '../../composables/useDashboardData'
|
|
3
|
+
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
stats: DashboardStats
|
|
8
|
+
loading: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const cards = computed(() => [
|
|
12
|
+
{
|
|
13
|
+
label: t('motor-admin.dashboard.stats.pages'),
|
|
14
|
+
value: props.stats.pages_total,
|
|
15
|
+
subtitle: t('motor-admin.dashboard.stats.pages_total'),
|
|
16
|
+
icon: 'i-lucide-file-text',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
label: t('motor-admin.dashboard.stats.drafts'),
|
|
20
|
+
value: props.stats.pages_draft,
|
|
21
|
+
subtitle: t('motor-admin.dashboard.stats.drafts_subtitle'),
|
|
22
|
+
icon: 'i-lucide-pencil',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: t('motor-admin.dashboard.stats.scheduled'),
|
|
26
|
+
value: props.stats.pages_scheduled,
|
|
27
|
+
subtitle: t('motor-admin.dashboard.stats.scheduled_subtitle'),
|
|
28
|
+
icon: 'i-lucide-clock',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: t('motor-admin.dashboard.stats.media'),
|
|
32
|
+
value: props.stats.media_total,
|
|
33
|
+
subtitle: t('motor-admin.dashboard.stats.media_subtitle'),
|
|
34
|
+
icon: 'i-lucide-image',
|
|
35
|
+
},
|
|
36
|
+
])
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
41
|
+
<UPageCard
|
|
42
|
+
v-for="card in cards"
|
|
43
|
+
:key="card.label"
|
|
44
|
+
:ui="{ root: 'border-0 shadow-none' }"
|
|
45
|
+
>
|
|
46
|
+
<div class="flex items-center gap-3 mb-2">
|
|
47
|
+
<div class="flex items-center justify-center size-8 rounded-lg bg-primary/10">
|
|
48
|
+
<UIcon
|
|
49
|
+
:name="card.icon"
|
|
50
|
+
class="size-4 text-primary"
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
<span class="text-sm text-muted">{{ card.label }}</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="text-3xl font-heading font-bold text-highlighted">
|
|
56
|
+
{{ loading ? '—' : card.value }}
|
|
57
|
+
</div>
|
|
58
|
+
<div class="text-xs text-dimmed mt-1">
|
|
59
|
+
{{ card.subtitle }}
|
|
60
|
+
</div>
|
|
61
|
+
</UPageCard>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
export interface DashboardStats {
|
|
2
|
+
pages_total: number
|
|
3
|
+
pages_draft: number
|
|
4
|
+
pages_published: number
|
|
5
|
+
pages_scheduled: number
|
|
6
|
+
media_total: number
|
|
7
|
+
navigation_trees: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ActivityItem {
|
|
11
|
+
id: number
|
|
12
|
+
description: string
|
|
13
|
+
subject_type: string
|
|
14
|
+
subject_id: number
|
|
15
|
+
subject_name: string | null
|
|
16
|
+
subject_exists: boolean
|
|
17
|
+
causer_name: string | null
|
|
18
|
+
created_at: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PublishingQueueItem {
|
|
22
|
+
id: number
|
|
23
|
+
name: string
|
|
24
|
+
to_be_published_at: string
|
|
25
|
+
publishable_type: string
|
|
26
|
+
publishable_id: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AnnouncementItem {
|
|
30
|
+
id: number
|
|
31
|
+
title: string
|
|
32
|
+
body: string | null
|
|
33
|
+
type: 'info' | 'warning' | 'error'
|
|
34
|
+
audience: 'self' | 'users' | 'client'
|
|
35
|
+
linkable_type: string | null
|
|
36
|
+
linkable_id: number | null
|
|
37
|
+
linkable_name: string | null
|
|
38
|
+
linkable_url: string | null
|
|
39
|
+
created_by_name: string | null
|
|
40
|
+
created_at: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ActivityMeta {
|
|
44
|
+
current_page: number
|
|
45
|
+
last_page: number
|
|
46
|
+
per_page: number
|
|
47
|
+
total: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface DashboardResponse {
|
|
51
|
+
data: {
|
|
52
|
+
stats: DashboardStats
|
|
53
|
+
activity: ActivityItem[]
|
|
54
|
+
activity_meta: ActivityMeta
|
|
55
|
+
publishing_queue: PublishingQueueItem[]
|
|
56
|
+
announcements: AnnouncementItem[]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NOTIFIED_STORAGE_KEY = 'dashboard-announcements-notified'
|
|
61
|
+
|
|
62
|
+
function getNotifiedIds(): Set<number> {
|
|
63
|
+
try {
|
|
64
|
+
const raw = localStorage.getItem(NOTIFIED_STORAGE_KEY)
|
|
65
|
+
return new Set(raw ? JSON.parse(raw) : [])
|
|
66
|
+
} catch {
|
|
67
|
+
return new Set()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function persistNotifiedIds(ids: Set<number>) {
|
|
72
|
+
localStorage.setItem(NOTIFIED_STORAGE_KEY, JSON.stringify([...ids]))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useDashboardData() {
|
|
76
|
+
const client = useSanctumClient()
|
|
77
|
+
const { notify } = useNotify()
|
|
78
|
+
|
|
79
|
+
const stats = ref<DashboardStats>({
|
|
80
|
+
pages_total: 0,
|
|
81
|
+
pages_draft: 0,
|
|
82
|
+
pages_published: 0,
|
|
83
|
+
pages_scheduled: 0,
|
|
84
|
+
media_total: 0,
|
|
85
|
+
navigation_trees: 0,
|
|
86
|
+
})
|
|
87
|
+
const activity = ref<ActivityItem[]>([])
|
|
88
|
+
const activityMeta = ref<ActivityMeta>({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
|
89
|
+
const activityLoadingMore = ref(false)
|
|
90
|
+
const publishingQueue = ref<PublishingQueueItem[]>([])
|
|
91
|
+
const announcements = ref<AnnouncementItem[]>([])
|
|
92
|
+
const loading = ref(true)
|
|
93
|
+
|
|
94
|
+
const hasMoreActivity = computed(() => activityMeta.value.current_page < activityMeta.value.last_page)
|
|
95
|
+
|
|
96
|
+
async function refresh() {
|
|
97
|
+
loading.value = true
|
|
98
|
+
try {
|
|
99
|
+
const response = await client<DashboardResponse>('/api/v2/dashboard')
|
|
100
|
+
stats.value = response.data.stats
|
|
101
|
+
activity.value = response.data.activity
|
|
102
|
+
activityMeta.value = response.data.activity_meta
|
|
103
|
+
publishingQueue.value = response.data.publishing_queue
|
|
104
|
+
announcements.value = response.data.announcements
|
|
105
|
+
|
|
106
|
+
const notified = getNotifiedIds()
|
|
107
|
+
for (const a of response.data.announcements) {
|
|
108
|
+
if (!notified.has(a.id)) {
|
|
109
|
+
notify({
|
|
110
|
+
title: a.title,
|
|
111
|
+
description: a.body ?? undefined,
|
|
112
|
+
color: a.type === 'error' ? 'error' : a.type === 'warning' ? 'warning' : 'info',
|
|
113
|
+
icon: a.type === 'error' ? 'i-lucide-alert-circle' : a.type === 'warning' ? 'i-lucide-alert-triangle' : 'i-lucide-megaphone',
|
|
114
|
+
})
|
|
115
|
+
notified.add(a.id)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
persistNotifiedIds(notified)
|
|
119
|
+
} finally {
|
|
120
|
+
loading.value = false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadMoreActivity() {
|
|
125
|
+
if (!hasMoreActivity.value || activityLoadingMore.value) return
|
|
126
|
+
activityLoadingMore.value = true
|
|
127
|
+
try {
|
|
128
|
+
const nextPage = activityMeta.value.current_page + 1
|
|
129
|
+
const response = await client<DashboardResponse>(`/api/v2/dashboard?activity_page=${nextPage}`)
|
|
130
|
+
activity.value.push(...response.data.activity)
|
|
131
|
+
activityMeta.value = response.data.activity_meta
|
|
132
|
+
} finally {
|
|
133
|
+
activityLoadingMore.value = false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function dismissAnnouncement(id: number) {
|
|
138
|
+
await client(`/api/v2/dashboard/announcements/${id}/dismiss`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
})
|
|
141
|
+
announcements.value = announcements.value.filter(a => a.id !== id)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function createAnnouncement(data: Record<string, unknown>) {
|
|
145
|
+
await client('/api/v2/dashboard/announcements', {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
body: data,
|
|
148
|
+
})
|
|
149
|
+
await refresh()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
refresh().catch(() => {})
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
stats,
|
|
156
|
+
activity,
|
|
157
|
+
activityMeta,
|
|
158
|
+
activityLoadingMore,
|
|
159
|
+
hasMoreActivity,
|
|
160
|
+
publishingQueue,
|
|
161
|
+
announcements,
|
|
162
|
+
loading,
|
|
163
|
+
refresh,
|
|
164
|
+
loadMoreActivity,
|
|
165
|
+
dismissAnnouncement,
|
|
166
|
+
createAnnouncement,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"welcome": "Willkommen, {name}",
|
|
3
|
+
"announcement_created": "Meldung erstellt",
|
|
4
|
+
"announcement_dismissed": "Meldung ausgeblendet",
|
|
5
|
+
"stats": {
|
|
6
|
+
"pages": "Seiten",
|
|
7
|
+
"pages_total": "Gesamt",
|
|
8
|
+
"drafts": "Entwürfe",
|
|
9
|
+
"drafts_subtitle": "In Bearbeitung",
|
|
10
|
+
"scheduled": "Geplant",
|
|
11
|
+
"scheduled_subtitle": "Veröffentlichung",
|
|
12
|
+
"media": "Medien",
|
|
13
|
+
"media_subtitle": "Dateien"
|
|
14
|
+
},
|
|
15
|
+
"activity": {
|
|
16
|
+
"title": "Letzte Aktivitäten",
|
|
17
|
+
"empty": "Noch keine Aktivitäten",
|
|
18
|
+
"unknown": "Unbekannt",
|
|
19
|
+
"system": "System",
|
|
20
|
+
"verbs": {
|
|
21
|
+
"created": "erstellt",
|
|
22
|
+
"updated": "aktualisiert",
|
|
23
|
+
"deleted": "gelöscht",
|
|
24
|
+
"published": "veröffentlicht",
|
|
25
|
+
"unpublished": "offline genommen"
|
|
26
|
+
},
|
|
27
|
+
"types": {
|
|
28
|
+
"page": "Seite",
|
|
29
|
+
"navigation": "Navigation",
|
|
30
|
+
"media": "Medien",
|
|
31
|
+
"content_type": "Inhaltstyp",
|
|
32
|
+
"assistant": "Assistent",
|
|
33
|
+
"scoring": "Scoring",
|
|
34
|
+
"scheduled": "Geplant",
|
|
35
|
+
"approval": "Freigabe",
|
|
36
|
+
"seo": "SEO",
|
|
37
|
+
"search": "Suche"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"announcements": {
|
|
41
|
+
"title": "Meldungen",
|
|
42
|
+
"empty": "Keine Meldungen",
|
|
43
|
+
"modal_title": "Neue Meldung",
|
|
44
|
+
"field_title": "Titel",
|
|
45
|
+
"field_title_placeholder": "Meldung eingeben...",
|
|
46
|
+
"field_body": "Beschreibung",
|
|
47
|
+
"field_body_placeholder": "Optionale Details...",
|
|
48
|
+
"field_type": "Typ",
|
|
49
|
+
"field_audience": "Zielgruppe",
|
|
50
|
+
"field_users": "Nutzer auswählen",
|
|
51
|
+
"field_users_placeholder": "Nutzer suchen...",
|
|
52
|
+
"field_link": "Verknüpfung (optional)",
|
|
53
|
+
"field_link_type_placeholder": "Typ wählen...",
|
|
54
|
+
"field_link_item": "Element",
|
|
55
|
+
"field_link_item_placeholder": "Element suchen...",
|
|
56
|
+
"field_starts_at": "Sichtbar ab",
|
|
57
|
+
"field_expires_at": "Sichtbar bis",
|
|
58
|
+
"cancel": "Abbrechen",
|
|
59
|
+
"create": "Erstellen",
|
|
60
|
+
"type_info": "Info",
|
|
61
|
+
"type_warning": "Warnung",
|
|
62
|
+
"type_error": "Fehler",
|
|
63
|
+
"audience_self": "Nur für mich",
|
|
64
|
+
"audience_users": "Bestimmte Nutzer",
|
|
65
|
+
"audience_client": "Alle im Mandanten",
|
|
66
|
+
"linkable_page": "Seite",
|
|
67
|
+
"linkable_navigation": "Navigationspunkt",
|
|
68
|
+
"linkable_file": "Datei",
|
|
69
|
+
"linkable_content_type": "Inhaltstyp"
|
|
70
|
+
},
|
|
71
|
+
"quick_actions": {
|
|
72
|
+
"title": "Schnellaktionen",
|
|
73
|
+
"new_page": "Neue Seite",
|
|
74
|
+
"upload_media": "Medien hochladen",
|
|
75
|
+
"navigation": "Navigation"
|
|
76
|
+
},
|
|
77
|
+
"publishing_queue": {
|
|
78
|
+
"title": "Geplante Veröffentlichungen",
|
|
79
|
+
"empty": "Keine geplanten Veröffentlichungen"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"welcome": "Welcome, {name}",
|
|
3
|
+
"announcement_created": "Announcement created",
|
|
4
|
+
"announcement_dismissed": "Announcement dismissed",
|
|
5
|
+
"stats": {
|
|
6
|
+
"pages": "Pages",
|
|
7
|
+
"pages_total": "Total",
|
|
8
|
+
"drafts": "Drafts",
|
|
9
|
+
"drafts_subtitle": "In progress",
|
|
10
|
+
"scheduled": "Scheduled",
|
|
11
|
+
"scheduled_subtitle": "Publishing",
|
|
12
|
+
"media": "Media",
|
|
13
|
+
"media_subtitle": "Files"
|
|
14
|
+
},
|
|
15
|
+
"activity": {
|
|
16
|
+
"title": "Recent Activity",
|
|
17
|
+
"empty": "No activity yet",
|
|
18
|
+
"unknown": "Unknown",
|
|
19
|
+
"system": "System",
|
|
20
|
+
"verbs": {
|
|
21
|
+
"created": "created",
|
|
22
|
+
"updated": "updated",
|
|
23
|
+
"deleted": "deleted",
|
|
24
|
+
"published": "published",
|
|
25
|
+
"unpublished": "unpublished"
|
|
26
|
+
},
|
|
27
|
+
"types": {
|
|
28
|
+
"page": "Page",
|
|
29
|
+
"navigation": "Navigation",
|
|
30
|
+
"media": "Media",
|
|
31
|
+
"content_type": "Content type",
|
|
32
|
+
"assistant": "Assistant",
|
|
33
|
+
"scoring": "Scoring",
|
|
34
|
+
"scheduled": "Scheduled",
|
|
35
|
+
"approval": "Approval",
|
|
36
|
+
"seo": "SEO",
|
|
37
|
+
"search": "Search"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"announcements": {
|
|
41
|
+
"title": "Announcements",
|
|
42
|
+
"empty": "No announcements",
|
|
43
|
+
"modal_title": "New Announcement",
|
|
44
|
+
"field_title": "Title",
|
|
45
|
+
"field_title_placeholder": "Enter announcement...",
|
|
46
|
+
"field_body": "Description",
|
|
47
|
+
"field_body_placeholder": "Optional details...",
|
|
48
|
+
"field_type": "Type",
|
|
49
|
+
"field_audience": "Audience",
|
|
50
|
+
"field_users": "Select users",
|
|
51
|
+
"field_users_placeholder": "Search users...",
|
|
52
|
+
"field_link": "Link (optional)",
|
|
53
|
+
"field_link_type_placeholder": "Select type...",
|
|
54
|
+
"field_link_item": "Item",
|
|
55
|
+
"field_link_item_placeholder": "Search item...",
|
|
56
|
+
"field_starts_at": "Visible from",
|
|
57
|
+
"field_expires_at": "Visible until",
|
|
58
|
+
"cancel": "Cancel",
|
|
59
|
+
"create": "Create",
|
|
60
|
+
"type_info": "Info",
|
|
61
|
+
"type_warning": "Warning",
|
|
62
|
+
"type_error": "Error",
|
|
63
|
+
"audience_self": "Only me",
|
|
64
|
+
"audience_users": "Specific users",
|
|
65
|
+
"audience_client": "Everyone in client",
|
|
66
|
+
"linkable_page": "Page",
|
|
67
|
+
"linkable_navigation": "Navigation item",
|
|
68
|
+
"linkable_file": "File",
|
|
69
|
+
"linkable_content_type": "Content type"
|
|
70
|
+
},
|
|
71
|
+
"quick_actions": {
|
|
72
|
+
"title": "Quick Actions",
|
|
73
|
+
"new_page": "New Page",
|
|
74
|
+
"upload_media": "Upload Media",
|
|
75
|
+
"navigation": "Navigation"
|
|
76
|
+
},
|
|
77
|
+
"publishing_queue": {
|
|
78
|
+
"title": "Scheduled Publications",
|
|
79
|
+
"empty": "No scheduled publications"
|
|
80
|
+
}
|
|
81
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -1,39 +1,117 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { User } from '@motor-cms/ui-core/app/types/auth'
|
|
3
3
|
|
|
4
|
+
definePageMeta({
|
|
5
|
+
permission: 'dashboard.read',
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
const { t, locale } = useI18n()
|
|
4
9
|
const { user } = useSanctumAuth<User>()
|
|
5
|
-
const {
|
|
6
|
-
const { t } = useI18n()
|
|
10
|
+
const { can } = usePermissions()
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
const userData = computed(() => user.value?.data)
|
|
12
|
+
const canCreateAnnouncements = computed(() => can('dashboard-announcements.write'))
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
const {
|
|
15
|
+
stats,
|
|
16
|
+
activity,
|
|
17
|
+
activityLoadingMore,
|
|
18
|
+
hasMoreActivity,
|
|
19
|
+
publishingQueue,
|
|
20
|
+
announcements,
|
|
21
|
+
loading,
|
|
22
|
+
refresh,
|
|
23
|
+
loadMoreActivity,
|
|
24
|
+
dismissAnnouncement,
|
|
25
|
+
} = useDashboardData()
|
|
26
|
+
|
|
27
|
+
const announcementModalOpen = ref(false)
|
|
28
|
+
|
|
29
|
+
const currentDate = computed(() => {
|
|
30
|
+
return new Date().toLocaleDateString(locale.value, {
|
|
31
|
+
weekday: 'long',
|
|
32
|
+
day: 'numeric',
|
|
33
|
+
month: 'long',
|
|
34
|
+
year: 'numeric',
|
|
35
|
+
})
|
|
14
36
|
})
|
|
37
|
+
|
|
38
|
+
function onAnnouncementCreated() {
|
|
39
|
+
refresh()
|
|
40
|
+
toast.add({ title: t('motor-admin.dashboard.announcement_created'), icon: 'i-lucide-megaphone', color: 'success' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const toast = useToast()
|
|
44
|
+
|
|
45
|
+
async function onDismiss(id: number) {
|
|
46
|
+
await dismissAnnouncement(id)
|
|
47
|
+
toast.add({ title: t('motor-admin.dashboard.announcement_dismissed'), icon: 'i-lucide-check', color: 'success' })
|
|
48
|
+
}
|
|
15
49
|
</script>
|
|
16
50
|
|
|
17
51
|
<template>
|
|
18
52
|
<div class="p-6">
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
53
|
+
<div class="flex items-start justify-between mb-6">
|
|
54
|
+
<div>
|
|
55
|
+
<h1 class="text-xl font-heading font-semibold text-highlighted">
|
|
56
|
+
{{ t('motor-admin.dashboard.welcome', { name: user?.data?.name ?? '' }) }}
|
|
57
|
+
</h1>
|
|
58
|
+
<p class="text-sm text-dimmed mt-1">
|
|
59
|
+
{{ currentDate }}
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="flex items-center gap-2">
|
|
63
|
+
<UButton
|
|
64
|
+
icon="i-lucide-plus"
|
|
65
|
+
color="primary"
|
|
66
|
+
size="sm"
|
|
67
|
+
:label="t('motor-admin.dashboard.quick_actions.new_page')"
|
|
68
|
+
@click="navigateTo('/motor-builder/builder-pages/create')"
|
|
69
|
+
/>
|
|
70
|
+
<UButton
|
|
71
|
+
icon="i-lucide-upload"
|
|
72
|
+
color="neutral"
|
|
73
|
+
variant="outline"
|
|
74
|
+
size="sm"
|
|
75
|
+
:label="t('motor-admin.dashboard.quick_actions.upload_media')"
|
|
76
|
+
@click="navigateTo('/motor-media/files/create')"
|
|
77
|
+
/>
|
|
78
|
+
<UButton
|
|
79
|
+
icon="i-lucide-menu"
|
|
80
|
+
color="neutral"
|
|
81
|
+
variant="outline"
|
|
82
|
+
size="sm"
|
|
83
|
+
:label="t('motor-admin.dashboard.quick_actions.navigation')"
|
|
84
|
+
to="/motor-builder/navigation-trees"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
22
87
|
</div>
|
|
23
88
|
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
89
|
+
<DashboardStats :stats="stats" :loading="loading" class="mb-6" />
|
|
90
|
+
|
|
91
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
92
|
+
<DashboardActivity
|
|
93
|
+
:items="activity"
|
|
94
|
+
:loading="loading"
|
|
95
|
+
:loading-more="activityLoadingMore"
|
|
96
|
+
:has-more="hasMoreActivity"
|
|
97
|
+
@load-more="loadMoreActivity"
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
<div class="flex flex-col gap-4">
|
|
101
|
+
<DashboardAnnouncements
|
|
102
|
+
:items="announcements"
|
|
103
|
+
:loading="loading"
|
|
104
|
+
:can-create="canCreateAnnouncements"
|
|
105
|
+
@dismiss="onDismiss"
|
|
106
|
+
@create="announcementModalOpen = true"
|
|
107
|
+
/>
|
|
108
|
+
<DashboardPublishingQueue :items="publishingQueue" :loading="loading" />
|
|
109
|
+
</div>
|
|
28
110
|
</div>
|
|
29
111
|
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<p class="text-sm text-muted">
|
|
35
|
-
{{ t('motor-core.global.dashboard_description', { clientName }) }}
|
|
36
|
-
</p>
|
|
37
|
-
</UPageCard>
|
|
112
|
+
<DashboardAnnouncementModal
|
|
113
|
+
v-model:open="announcementModalOpen"
|
|
114
|
+
@created="onAnnouncementCreated"
|
|
115
|
+
/>
|
|
38
116
|
</div>
|
|
39
117
|
</template>
|
package/app/pages/search.vue
CHANGED
|
@@ -123,16 +123,20 @@ async function handleCardAction(key: string, id: number | string | null, meta: R
|
|
|
123
123
|
quicklinkerPageName.value = (meta.name as string) ?? ''
|
|
124
124
|
quicklinkerOpen.value = true
|
|
125
125
|
} else if (key === 'publish' && id) {
|
|
126
|
+
const isPublished = !!meta?.is_published
|
|
126
127
|
try {
|
|
127
128
|
await client(`/api/v2/builder-pages/${id}/publication`, {
|
|
128
129
|
method: 'PUT',
|
|
129
|
-
body: { is_published:
|
|
130
|
+
body: { is_published: !isPublished }
|
|
130
131
|
})
|
|
131
132
|
toast.add({
|
|
132
|
-
title:
|
|
133
|
+
title: !isPublished
|
|
134
|
+
? t('motor-builder.builder_pages.toast_published')
|
|
135
|
+
: t('motor-builder.builder_pages.toast_unpublished'),
|
|
133
136
|
color: 'success',
|
|
134
|
-
icon: 'i-lucide-globe'
|
|
137
|
+
icon: !isPublished ? 'i-lucide-globe' : 'i-lucide-globe-lock'
|
|
135
138
|
})
|
|
139
|
+
doSearch()
|
|
136
140
|
} catch {
|
|
137
141
|
toast.add({
|
|
138
142
|
title: t('motor-builder.builder_pages.toast_publish_error'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motor-cms/ui-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@vueuse/core": "^14.0.0",
|
|
18
18
|
"sortablejs": "^1.15.0",
|
|
19
19
|
"zod": "^4.0.0",
|
|
20
|
-
"@motor-cms/ui-core": "1.
|
|
20
|
+
"@motor-cms/ui-core": "1.4.0"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"nuxt": "^4.0.0",
|