@motor-cms/ui-admin 1.1.0-alpha.3 → 1.1.0-alpha.5

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.
Files changed (73) hide show
  1. package/app/assets/css/v-onboarding.css +64 -0
  2. package/app/components/OnboardingStep.vue +42 -0
  3. package/app/components/UsersOnboarding.vue +84 -0
  4. package/app/components/client/FooterSlotCard.vue +313 -0
  5. package/app/components/client/GlobalComponentsSection.vue +65 -0
  6. package/app/components/dashboard/DashboardActivity.vue +71 -0
  7. package/app/components/dashboard/DashboardActivityItem.vue +96 -0
  8. package/app/components/dashboard/DashboardAnnouncementModal.vue +327 -0
  9. package/app/components/dashboard/DashboardAnnouncements.vue +93 -0
  10. package/app/components/dashboard/DashboardOnboarding.vue +285 -0
  11. package/app/components/dashboard/DashboardPublishingQueue.vue +47 -0
  12. package/app/components/dashboard/DashboardQuickActions.vue +44 -0
  13. package/app/components/dashboard/DashboardStats.vue +63 -0
  14. package/app/components/form/inputs/CategoryTreePicker.vue +265 -109
  15. package/app/components/form/inputs/EntityConfigurationsPanel.vue +235 -0
  16. package/app/composables/useClientFormExtensions.ts +89 -0
  17. package/app/composables/useClientLanguages.ts +81 -0
  18. package/app/composables/useDashboardData.ts +169 -0
  19. package/app/composables/useOnboardingState.ts +151 -0
  20. package/app/data/footerTemplate.ts +283 -0
  21. package/app/lang/de/motor-admin/ai_system_prompts.json +1 -0
  22. package/app/lang/de/motor-admin/categories.json +1 -0
  23. package/app/lang/de/motor-admin/category_trees.json +2 -1
  24. package/app/lang/de/motor-admin/clients.json +17 -1
  25. package/app/lang/de/motor-admin/config_variables.json +1 -0
  26. package/app/lang/de/motor-admin/dashboard.json +83 -0
  27. package/app/lang/de/motor-admin/domains.json +6 -1
  28. package/app/lang/de/motor-admin/email_templates.json +1 -0
  29. package/app/lang/de/motor-admin/entity_configurations.json +12 -0
  30. package/app/lang/de/motor-admin/languages.json +1 -0
  31. package/app/lang/de/motor-admin/onboarding.json +60 -0
  32. package/app/lang/de/motor-admin/permissions.json +1 -0
  33. package/app/lang/de/motor-admin/roles.json +1 -0
  34. package/app/lang/de/motor-admin/users.json +1 -0
  35. package/app/lang/en/motor-admin/ai_system_prompts.json +1 -0
  36. package/app/lang/en/motor-admin/categories.json +1 -0
  37. package/app/lang/en/motor-admin/category_trees.json +2 -1
  38. package/app/lang/en/motor-admin/clients.json +17 -1
  39. package/app/lang/en/motor-admin/config_variables.json +1 -0
  40. package/app/lang/en/motor-admin/dashboard.json +83 -0
  41. package/app/lang/en/motor-admin/domains.json +6 -1
  42. package/app/lang/en/motor-admin/email_templates.json +1 -0
  43. package/app/lang/en/motor-admin/entity_configurations.json +12 -0
  44. package/app/lang/en/motor-admin/languages.json +1 -0
  45. package/app/lang/en/motor-admin/onboarding.json +60 -0
  46. package/app/lang/en/motor-admin/permissions.json +1 -0
  47. package/app/lang/en/motor-admin/roles.json +1 -0
  48. package/app/lang/en/motor-admin/users.json +1 -0
  49. package/app/pages/index.vue +119 -22
  50. package/app/pages/login.vue +6 -0
  51. package/app/pages/motor-admin/ai-system-prompts/[id]/edit.vue +4 -4
  52. package/app/pages/motor-admin/category-trees/[id]/categories/[categoryId]/edit.vue +4 -3
  53. package/app/pages/motor-admin/category-trees/[id]/edit.vue +4 -4
  54. package/app/pages/motor-admin/clients/[id]/edit.vue +146 -6
  55. package/app/pages/motor-admin/clients/create.vue +34 -2
  56. package/app/pages/motor-admin/config-variables/[id]/edit.vue +4 -4
  57. package/app/pages/motor-admin/domains/[id]/edit.vue +18 -5
  58. package/app/pages/motor-admin/email-templates/[id]/edit.vue +4 -4
  59. package/app/pages/motor-admin/email-templates/index.vue +36 -25
  60. package/app/pages/motor-admin/languages/[id]/edit.vue +17 -4
  61. package/app/pages/motor-admin/languages/create.vue +13 -0
  62. package/app/pages/motor-admin/permission-groups/[id]/edit.vue +4 -4
  63. package/app/pages/motor-admin/roles/[id]/edit.vue +4 -4
  64. package/app/pages/motor-admin/roles/create.vue +4 -1
  65. package/app/pages/motor-admin/users/[id]/edit.vue +4 -3
  66. package/app/pages/motor-admin/users/index.vue +1 -0
  67. package/app/pages/profile.vue +47 -1
  68. package/app/pages/search.vue +13 -3
  69. package/app/types/generated/form-meta.ts +24 -20
  70. package/app/types/generated/grid-meta.ts +5 -3
  71. package/nuxt.config.ts +15 -1
  72. package/package.json +6 -2
  73. package/app/pages/dashboard.vue +0 -5
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { formatTimeAgoIntl } from '@vueuse/core'
3
+ import type { ActivityItem } from '../../composables/useDashboardData'
4
+
5
+ const { t, locale } = 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; {{ formatTimeAgoIntl(timestamp, { locale }) }}
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,327 @@
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 apiClient = useSanctumClient()
13
+ const { can, hasRole } = usePermissions()
14
+ const { user } = useSanctumAuth<import('@motor-cms/ui-core/app/types/auth').User>()
15
+ const saving = ref(false)
16
+
17
+ const canWriteAnnouncements = computed(() => can('dashboard-announcements.write'))
18
+
19
+ const form = reactive({
20
+ title: '',
21
+ body: '',
22
+ type: 'info',
23
+ audience: 'self',
24
+ target_user_ids: [] as number[],
25
+ client_id: undefined as number | undefined,
26
+ linkable_type: '',
27
+ linkable_id: undefined as number | undefined,
28
+ starts_at: undefined as string | undefined,
29
+ expires_at: undefined as string | undefined,
30
+ })
31
+
32
+ const typeOptions = computed(() => [
33
+ { label: t('motor-admin.dashboard.announcements.type_info'), value: 'info' },
34
+ { label: t('motor-admin.dashboard.announcements.type_warning'), value: 'warning' },
35
+ { label: t('motor-admin.dashboard.announcements.type_error'), value: 'error' },
36
+ ])
37
+
38
+ const allAudienceOptions = computed(() => [
39
+ { label: t('motor-admin.dashboard.announcements.audience_self'), value: 'self' },
40
+ { label: t('motor-admin.dashboard.announcements.audience_users'), value: 'users' },
41
+ { label: t('motor-admin.dashboard.announcements.audience_client'), value: 'client' },
42
+ ])
43
+
44
+ const audienceOptions = computed(() =>
45
+ canWriteAnnouncements.value
46
+ ? allAudienceOptions.value
47
+ : allAudienceOptions.value.filter(o => o.value === 'self')
48
+ )
49
+
50
+ const linkableTypeOptions = computed(() => [
51
+ { label: t('motor-admin.dashboard.announcements.linkable_page'), value: 'Motor\\Builder\\Models\\BuilderPage' },
52
+ { label: t('motor-admin.dashboard.announcements.linkable_navigation'), value: 'Motor\\Builder\\Models\\Navigation' },
53
+ { label: t('motor-admin.dashboard.announcements.linkable_file'), value: 'Motor\\Media\\Models\\File' },
54
+ { label: t('motor-admin.dashboard.announcements.linkable_content_type'), value: 'Motor\\ContentType\\Models\\CustomContentType' },
55
+ ])
56
+
57
+ const userOptions = ref<Array<{ label: string; value: number }>>([])
58
+ const usersLoading = ref(false)
59
+ const usersFetched = ref(false)
60
+
61
+ const clientOptions = computed(() => {
62
+ return (user.value?.data?.clients ?? []).map(c => ({
63
+ label: c.name,
64
+ value: c.id,
65
+ }))
66
+ })
67
+
68
+ const allClientOptions = ref<Array<{ label: string; value: number }>>([])
69
+ const allClientsFetched = ref(false)
70
+ const allClientsLoading = ref(false)
71
+
72
+ const visibleClientOptions = computed(() => {
73
+ if (hasRole('SuperAdmin') && allClientsFetched.value) {
74
+ return allClientOptions.value
75
+ }
76
+ return clientOptions.value
77
+ })
78
+
79
+ watch(() => form.audience, async (audience) => {
80
+ if (audience === 'users' && !usersFetched.value) {
81
+ usersLoading.value = true
82
+ try {
83
+ const response = await apiClient<{ data: Array<{ id: number; name: string }> }>('/api/v2/users?per_page=200')
84
+ userOptions.value = response.data.map(u => ({
85
+ label: u.name,
86
+ value: u.id,
87
+ }))
88
+ usersFetched.value = true
89
+ } finally {
90
+ usersLoading.value = false
91
+ }
92
+ }
93
+ if (audience === 'client' && hasRole('SuperAdmin') && !allClientsFetched.value) {
94
+ allClientsLoading.value = true
95
+ try {
96
+ const response = await apiClient<{ data: Array<{ id: number; name: string }> }>('/api/v2/clients?per_page=200')
97
+ allClientOptions.value = response.data.map(c => ({
98
+ label: c.name,
99
+ value: c.id,
100
+ }))
101
+ allClientsFetched.value = true
102
+ } finally {
103
+ allClientsLoading.value = false
104
+ }
105
+ }
106
+ })
107
+
108
+ const linkableOptions = ref<Array<{ label: string; value: number }>>([])
109
+ const linkableLoading = ref(false)
110
+ const linkableSearchTerm = ref('')
111
+ let linkableSearchTimeout: ReturnType<typeof setTimeout>
112
+
113
+ const linkableEndpoints: Record<string, { endpoint: string; labelFn: (item: Record<string, any>) => string }> = {
114
+ 'Motor\\Builder\\Models\\BuilderPage': {
115
+ endpoint: '/api/v2/builder-pages',
116
+ labelFn: item => item.name ?? `#${item.id}`,
117
+ },
118
+ 'Motor\\Builder\\Models\\Navigation': {
119
+ endpoint: '/api/v2/navigation-items',
120
+ labelFn: item => item.full_slug ? `${item.name} (${item.full_slug})` : item.name ?? `#${item.id}`,
121
+ },
122
+ 'Motor\\Media\\Models\\File': {
123
+ endpoint: '/api/v2/files',
124
+ labelFn: item => item.description || item.file?.file_name || `#${item.id}`,
125
+ },
126
+ 'Motor\\ContentType\\Models\\CustomContentType': {
127
+ endpoint: '/api/v2/custom-content-types',
128
+ labelFn: item => item.name ?? `#${item.id}`,
129
+ },
130
+ }
131
+
132
+ async function fetchLinkableOptions(query: string) {
133
+ const config = linkableEndpoints[form.linkable_type]
134
+ if (!config) return
135
+
136
+ linkableLoading.value = true
137
+ try {
138
+ const params = new URLSearchParams({ per_page: '20' })
139
+ if (query) {
140
+ params.set('search', query)
141
+ }
142
+ const response = await apiClient<{ data: Array<Record<string, any>> }>(`${config.endpoint}?${params}`)
143
+ linkableOptions.value = response.data.map(item => ({
144
+ label: config.labelFn(item),
145
+ value: item.id,
146
+ }))
147
+ } catch {
148
+ linkableOptions.value = []
149
+ } finally {
150
+ linkableLoading.value = false
151
+ }
152
+ }
153
+
154
+ watch(() => form.linkable_type, (type) => {
155
+ form.linkable_id = undefined
156
+ linkableOptions.value = []
157
+ linkableSearchTerm.value = ''
158
+ if (!type) return
159
+ fetchLinkableOptions('')
160
+ })
161
+
162
+ watch(linkableSearchTerm, (term) => {
163
+ clearTimeout(linkableSearchTimeout)
164
+ linkableSearchTimeout = setTimeout(() => fetchLinkableOptions(term), 300)
165
+ })
166
+
167
+ async function handleSubmit() {
168
+ saving.value = true
169
+ try {
170
+ const data: Record<string, unknown> = {
171
+ title: form.title,
172
+ body: form.body || null,
173
+ type: form.type,
174
+ audience: form.audience,
175
+ }
176
+
177
+ if (form.audience === 'users') {
178
+ data.target_user_ids = form.target_user_ids
179
+ }
180
+ if (form.audience === 'client' && form.client_id) {
181
+ data.client_id = form.client_id
182
+ }
183
+ if (form.linkable_type && form.linkable_id) {
184
+ data.linkable_type = form.linkable_type
185
+ data.linkable_id = form.linkable_id
186
+ }
187
+ if (form.starts_at) {
188
+ data.starts_at = form.starts_at
189
+ }
190
+ if (form.expires_at) {
191
+ data.expires_at = form.expires_at
192
+ }
193
+
194
+ await apiClient('/api/v2/dashboard/announcements', {
195
+ method: 'POST',
196
+ body: data,
197
+ })
198
+
199
+ Object.assign(form, {
200
+ title: '',
201
+ body: '',
202
+ type: 'info',
203
+ audience: 'self',
204
+ target_user_ids: [],
205
+ client_id: undefined,
206
+ linkable_type: '',
207
+ linkable_id: null,
208
+ starts_at: null,
209
+ expires_at: null,
210
+ })
211
+
212
+ emit('created')
213
+ emit('update:open', false)
214
+ } finally {
215
+ saving.value = false
216
+ }
217
+ }
218
+ </script>
219
+
220
+ <template>
221
+ <UModal
222
+ :open="open"
223
+ class="sm:max-w-xl"
224
+ @update:open="emit('update:open', $event)"
225
+ >
226
+ <template #header>
227
+ <span class="font-heading font-semibold">{{ t('motor-admin.dashboard.announcements.modal_title') }}</span>
228
+ </template>
229
+
230
+ <template #body>
231
+ <div class="flex flex-col gap-5">
232
+ <UFormField :label="t('motor-admin.dashboard.announcements.field_title')" required>
233
+ <UInput v-model="form.title" class="w-full" :placeholder="t('motor-admin.dashboard.announcements.field_title_placeholder')" />
234
+ </UFormField>
235
+
236
+ <UFormField :label="t('motor-admin.dashboard.announcements.field_body')">
237
+ <UTextarea v-model="form.body" class="w-full" :placeholder="t('motor-admin.dashboard.announcements.field_body_placeholder')" :rows="3" />
238
+ </UFormField>
239
+
240
+ <div class="grid grid-cols-2 gap-4">
241
+ <UFormField :label="t('motor-admin.dashboard.announcements.field_type')">
242
+ <USelectMenu v-model="form.type" :items="typeOptions" value-key="value" class="w-full" />
243
+ </UFormField>
244
+
245
+ <UFormField :label="t('motor-admin.dashboard.announcements.field_audience')">
246
+ <USelectMenu v-model="form.audience" :items="audienceOptions" value-key="value" class="w-full" />
247
+ </UFormField>
248
+ </div>
249
+
250
+ <UFormField v-if="form.audience === 'users'" :label="t('motor-admin.dashboard.announcements.field_users')">
251
+ <USelectMenu
252
+ v-model="form.target_user_ids"
253
+ :items="userOptions"
254
+ value-key="value"
255
+ multiple
256
+ :loading="usersLoading"
257
+ :placeholder="t('motor-admin.dashboard.announcements.field_users_placeholder')"
258
+ class="w-full"
259
+ />
260
+ </UFormField>
261
+
262
+ <UFormField v-if="form.audience === 'client'" :label="t('motor-admin.dashboard.announcements.field_client')">
263
+ <USelectMenu
264
+ v-model="form.client_id"
265
+ :items="visibleClientOptions"
266
+ value-key="value"
267
+ :loading="allClientsLoading"
268
+ :placeholder="t('motor-admin.dashboard.announcements.field_client_placeholder')"
269
+ class="w-full"
270
+ />
271
+ </UFormField>
272
+
273
+ <UFormField :label="t('motor-admin.dashboard.announcements.field_link')">
274
+ <USelectMenu
275
+ v-model="form.linkable_type"
276
+ :items="linkableTypeOptions"
277
+ value-key="value"
278
+ :placeholder="t('motor-admin.dashboard.announcements.field_link_type_placeholder')"
279
+ class="w-full"
280
+ />
281
+ </UFormField>
282
+
283
+ <UFormField v-if="form.linkable_type" :label="t('motor-admin.dashboard.announcements.field_link_item')">
284
+ <USelectMenu
285
+ v-model="form.linkable_id"
286
+ v-model:search-term="linkableSearchTerm"
287
+ :items="linkableOptions"
288
+ value-key="value"
289
+ ignore-filter
290
+ :loading="linkableLoading"
291
+ :placeholder="t('motor-admin.dashboard.announcements.field_link_item_placeholder')"
292
+ class="w-full"
293
+ />
294
+ </UFormField>
295
+
296
+ <div class="grid grid-cols-2 gap-4">
297
+ <UFormField :label="t('motor-admin.dashboard.announcements.field_starts_at')">
298
+ <UInput v-model="form.starts_at" type="datetime-local" class="w-full" />
299
+ </UFormField>
300
+ <UFormField :label="t('motor-admin.dashboard.announcements.field_expires_at')">
301
+ <UInput v-model="form.expires_at" type="datetime-local" class="w-full" />
302
+ </UFormField>
303
+ </div>
304
+ </div>
305
+ </template>
306
+
307
+ <template #footer>
308
+ <div class="flex justify-end gap-2">
309
+ <UButton
310
+ color="neutral"
311
+ variant="outline"
312
+ @click="emit('update:open', false)"
313
+ >
314
+ {{ t('motor-admin.dashboard.announcements.cancel') }}
315
+ </UButton>
316
+ <UButton
317
+ color="primary"
318
+ :loading="saving"
319
+ :disabled="!form.title"
320
+ @click="handleSubmit"
321
+ >
322
+ {{ t('motor-admin.dashboard.announcements.create') }}
323
+ </UButton>
324
+ </div>
325
+ </template>
326
+ </UModal>
327
+ </template>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import { formatTimeAgoIntl } 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, locale } = 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
+ <div id="onboarding-announcements-card">
27
+ <UPageCard :ui="{ root: 'border-0 shadow-none' }">
28
+ <template #header>
29
+ <div class="flex items-center justify-between w-full">
30
+ <div class="flex items-center gap-2">
31
+ <UIcon name="i-lucide-megaphone" class="size-4 text-primary" />
32
+ <span class="text-sm font-heading font-semibold text-highlighted">{{ t('motor-admin.dashboard.announcements.title') }}</span>
33
+ <UBadge
34
+ v-if="items.length > 0"
35
+ color="primary"
36
+ variant="subtle"
37
+ size="md"
38
+ >
39
+ {{ items.length }}
40
+ </UBadge>
41
+ </div>
42
+ <UButton
43
+ v-if="canCreate"
44
+ id="onboarding-announcements-create"
45
+ icon="i-lucide-plus"
46
+ size="xs"
47
+ variant="ghost"
48
+ color="neutral"
49
+ @click="emit('create')"
50
+ />
51
+ </div>
52
+ </template>
53
+ <div id="onboarding-announcements-body">
54
+ <div v-if="loading" class="flex items-center justify-center py-6 text-muted">
55
+ <UIcon name="i-lucide-loader-2" class="size-5 animate-spin" />
56
+ </div>
57
+ <div v-else-if="items.length === 0" class="text-sm text-muted py-4 text-center">
58
+ {{ t('motor-admin.dashboard.announcements.empty') }}
59
+ </div>
60
+ <div v-else class="flex flex-col gap-2">
61
+ <div
62
+ v-for="item in items"
63
+ :key="item.id"
64
+ class="p-3 rounded-lg bg-muted border-l-3"
65
+ :class="borderColors[item.type] ?? 'border-l-info'"
66
+ >
67
+ <div class="flex items-start justify-between gap-2">
68
+ <div class="text-sm font-medium text-default">{{ item.title }}</div>
69
+ <button
70
+ class="flex-shrink-0 text-dimmed hover:text-default transition-colors"
71
+ @click="emit('dismiss', item.id)"
72
+ >
73
+ <UIcon name="i-lucide-x" class="size-3.5" />
74
+ </button>
75
+ </div>
76
+ <p v-if="item.body" class="text-xs text-muted mt-1">{{ item.body }}</p>
77
+ <NuxtLink
78
+ v-if="item.linkable_url"
79
+ :to="item.linkable_url"
80
+ class="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-2"
81
+ >
82
+ <UIcon name="i-lucide-external-link" class="size-3" />
83
+ {{ item.linkable_name }}
84
+ </NuxtLink>
85
+ <div class="text-xs text-dimmed mt-2">
86
+ {{ item.created_by_name }} &middot; {{ formatTimeAgoIntl(new Date(item.starts_at ?? item.created_at), { locale }) }}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </UPageCard>
92
+ </div>
93
+ </template>