@motor-cms/ui-admin 1.2.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.
@@ -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') }} &middot; {{ 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 }} &middot; {{ 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
+ }
@@ -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 { clientName } = useRuntimeConfig().public
6
- const { t } = useI18n()
10
+ const { can } = usePermissions()
7
11
 
8
- // Helper to access user data
9
- const userData = computed(() => user.value?.data)
12
+ const canCreateAnnouncements = computed(() => can('dashboard-announcements.write'))
10
13
 
11
- definePageMeta({
12
- layout: 'default',
13
- permission: 'dashboard.read'
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
- <!-- Mobile sidebar toggle -->
20
- <div class="lg:hidden mb-3">
21
- <UDashboardSidebarToggle />
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
- <div class="mb-6">
25
- <h1 class="text-2xl font-bold">
26
- {{ t('motor-core.global.dashboard') }}
27
- </h1>
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
- <UPageCard :title="t('motor-core.global.welcome_back')">
31
- <template #description>
32
- {{ t('motor-core.global.logged_in_as') }} {{ userData?.name || userData?.email || 'User' }}.
33
- </template>
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>
@@ -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: true }
130
+ body: { is_published: !isPublished }
130
131
  })
131
132
  toast.add({
132
- title: t('motor-builder.builder_pages.toast_published'),
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.2.0",
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.2.0"
20
+ "@motor-cms/ui-core": "1.4.0"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "nuxt": "^4.0.0",
@@ -1,5 +0,0 @@
1
- <script setup lang="ts">
2
- definePageMeta({
3
- redirect: '/'
4
- })
5
- </script>