@motor-cms/ui-admin 1.1.0-alpha.4 → 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,64 @@
1
+ /* ==========================================================================
2
+ v-onboarding — Nuxt UI v4 theme integration
3
+ ========================================================================== */
4
+
5
+ /* The library (v-onboarding/nuxt) injects its own inline <style> tag after
6
+ our stylesheet, so we need either !important or higher specificity to win.
7
+ We use both: [data-v-onboarding-wrapper] adds an attribute selector that
8
+ raises specificity above the library's class-only selectors. */
9
+
10
+ :root {
11
+ --v-onboarding-overlay-z: 99998;
12
+ --v-onboarding-step-z: 99999;
13
+ }
14
+
15
+ [data-v-onboarding-wrapper] .v-onboarding-item {
16
+ background-color: var(--ui-bg-elevated) !important;
17
+ border: 1px solid var(--ui-border) !important;
18
+ border-radius: calc(var(--ui-radius) * 2) !important;
19
+ color: var(--ui-text) !important;
20
+ box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1) !important;
21
+ }
22
+
23
+ [data-v-onboarding-wrapper] .v-onboarding-item__header-title {
24
+ color: var(--ui-text-highlighted) !important;
25
+ font-weight: 600;
26
+ font-size: 0.9375rem;
27
+ }
28
+
29
+ [data-ing-wrapper] .v-onboarding-item__header-close:hover {
30
+ background-color: var(--ui-bg-accented) !important;
31
+ }
32
+
33
+ [data-v-onboarding-wrapper] .v-onboarding-item__description {
34
+ color: var(--ui-text-muted) !important;
35
+ }
36
+
37
+ [data-v-onboarding-wrapper] .v-onboarding-item__actions button.v-onboarding-btn-primary {
38
+ background-color: var(--ui-primary) !important;
39
+ border-color: transparent !important;
40
+ color: var(--ui-text-inverted) !important;
41
+ border-radius: calc(var(--ui-radius) * 2) !important;
42
+ }
43
+
44
+ [data-v-onboarding-wrapper] .v-onboarding-item__actions button.v-onboarding-btn-primary:hover {
45
+ background-color: color-mix(in srgb, var(--ui-primary) 88%, black) !important;
46
+ }
47
+
48
+ [data-v-onboarding-wrapper] .v-onboarding-item__actions button.v-onboarding-btn-primary:focus {
49
+ outline-color: var(--ui-primary) !important;
50
+ }
51
+
52
+ [data-v-onboarding-wrapper] .v-onboarding-item__actions button.v-onboarding-btn-secondary {
53
+ border-color: var(--ui-border-accented) !important;
54
+ color: var(--ui-text) !important;
55
+ border-radius: calc(var(--ui-radius) * 2) !important;
56
+ }
57
+
58
+ [data-v-onboarding-wrapper] .v-onboarding-item__actions button.v-onboarding-btn-secondary:hover {
59
+ background-color: var(--ui-bg-accented) !important;
60
+ }
61
+
62
+ [data-v-onboarding-wrapper] [data-popper-arrow]::before {
63
+ background: var(--ui-bg-elevated) !important;
64
+ }
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import type { StepEntity } from 'v-onboarding'
3
+
4
+ const { t } = useI18n()
5
+
6
+ defineProps<{
7
+ step: StepEntity | undefined
8
+ isFirst: boolean
9
+ isLast: boolean
10
+ next: () => void
11
+ previous: () => void
12
+ skip: () => void
13
+ }>()
14
+ </script>
15
+
16
+ <template>
17
+ <VOnboardingStep>
18
+ <div class="v-onboarding-item">
19
+ <div class="v-onboarding-item__header">
20
+ <span v-if="step?.content?.title" class="v-onboarding-item__header-title">
21
+ {{ step.content.title }}
22
+ </span>
23
+ <button type="button" class="v-onboarding-item__header-close" :aria-label="t('motor-admin.onboarding.skip')" @click="skip">
24
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
25
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
26
+ </svg>
27
+ </button>
28
+ </div>
29
+ <p v-if="step?.content?.description" class="v-onboarding-item__description">
30
+ {{ step.content.description }}
31
+ </p>
32
+ <div class="v-onboarding-item__actions">
33
+ <button v-if="!isFirst" type="button" class="v-onboarding-btn-secondary" @click="previous">
34
+ {{ t('motor-admin.onboarding.previous') }}
35
+ </button>
36
+ <button type="button" class="v-onboarding-btn-primary" @click="next">
37
+ {{ isLast ? t('motor-admin.onboarding.finish') : t('motor-admin.onboarding.next') }}
38
+ </button>
39
+ </div>
40
+ </div>
41
+ </VOnboardingStep>
42
+ </template>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ import { useVOnboarding } from 'v-onboarding'
3
+
4
+ const { t } = useI18n()
5
+
6
+ const { isCompleted: adminGridCompleted, markCompleted: markAdminGridDone } = useOnboardingState('admin-grid')
7
+ const { getPending, clearPending, setPending } = usePendingOnboarding()
8
+ const { completeOnboarding } = useProfileApi()
9
+ const { commitDone } = useOnboardingDone()
10
+ const router = useRouter()
11
+
12
+ const wrapper = ref(null)
13
+ const { start, finish: finishWrapper } = useVOnboarding(wrapper)
14
+
15
+ const steps = computed(() => [
16
+ {
17
+ attachTo: { element: '#users-grid' },
18
+ content: {
19
+ title: t('motor-admin.onboarding.admin_grid.step1_title'),
20
+ description: t('motor-admin.onboarding.admin_grid.step1_desc'),
21
+ },
22
+ },
23
+ ])
24
+
25
+ const options = computed(() => ({
26
+ scrollToStep: { enabled: true, options: { behavior: 'smooth' as ScrollBehavior, block: 'center' as ScrollLogicalPosition } },
27
+ popper: { strategy: 'fixed' as const },
28
+ labels: {
29
+ previousButton: t('motor-admin.onboarding.previous'),
30
+ nextButton: t('motor-admin.onboarding.next'),
31
+ finishButton: t('motor-admin.onboarding.finish'),
32
+ },
33
+ }))
34
+
35
+ // Prevents onFinish from navigating when the user clicked skip instead
36
+ const skipping = ref(false)
37
+
38
+ function onFinish() {
39
+ if (skipping.value) {
40
+ skipping.value = false
41
+ return
42
+ }
43
+ markAdminGridDone()
44
+ setPending('builder-pages')
45
+ router.push('/motor-builder/builder-pages')
46
+ }
47
+
48
+ function skipAdminGrid() {
49
+ commitDone()
50
+ skipping.value = true
51
+ markAdminGridDone()
52
+ completeOnboarding().catch(() => {})
53
+ finishWrapper()
54
+ }
55
+
56
+ // Auto-start when the page was reached via the admin-nav onboarding chain.
57
+ // The pending flag is set in DashboardOnboarding.vue after the admin-nav tour finishes.
58
+ const stopWatch = watch(
59
+ wrapper,
60
+ async (w) => {
61
+ if (!w) return
62
+ stopWatch()
63
+
64
+ if (getPending() === 'admin-grid' && !adminGridCompleted.value) {
65
+ clearPending()
66
+ start()
67
+ }
68
+ },
69
+ { immediate: true },
70
+ )
71
+ </script>
72
+
73
+ <template>
74
+ <VOnboardingWrapper
75
+ ref="wrapper"
76
+ :steps="steps"
77
+ :options="options"
78
+ @finish="onFinish"
79
+ >
80
+ <template #default="{ step, next, previous, isFirst, isLast }">
81
+ <OnboardingStep :step="step" :next="next" :previous="previous" :skip="skipAdminGrid" :is-first="isFirst" :is-last="isLast" />
82
+ </template>
83
+ </VOnboardingWrapper>
84
+ </template>
@@ -0,0 +1,313 @@
1
+ <!-- app/components/client/FooterSlotCard.vue -->
2
+ <script setup lang="ts">
3
+ import type { components } from '@motor-cms/ui-core/app/types/generated/api'
4
+ import { createFooterTemplate } from '../../data/footerTemplate'
5
+
6
+ type BuilderPageResource = components['schemas']['BuilderPageResource']
7
+
8
+ const props = defineProps<{
9
+ clientId: number | string
10
+ clientName: string
11
+ languageId: number
12
+ languageName?: string
13
+ showLanguageLabel: boolean
14
+ builderPageUuid: string | null
15
+ disabled?: boolean
16
+ }>()
17
+
18
+ const emit = defineEmits<{
19
+ 'linked': [uuid: string, pageId: number]
20
+ 'unlinked': []
21
+ }>()
22
+
23
+ const client = useSanctumClient()
24
+ const router = useRouter()
25
+ const route = useRoute()
26
+ const { t, locale } = useI18n()
27
+ const { success, error: notifyError } = useNotify()
28
+
29
+ // ============================================
30
+ // Page info state
31
+ // ============================================
32
+
33
+ interface PageInfo {
34
+ id: number
35
+ name: string
36
+ is_published: boolean
37
+ updated_at: string
38
+ }
39
+
40
+ const pageInfo = ref<PageInfo | null>(null)
41
+ const loadingPage = ref(false)
42
+ const creating = ref(false)
43
+
44
+ // ============================================
45
+ // Fetch page info on mount if UUID provided
46
+ // ============================================
47
+
48
+ async function fetchPageInfo(uuid: string): Promise<void> {
49
+ loadingPage.value = true
50
+ try {
51
+ const response = await client<{ data: BuilderPageResource }>(
52
+ `/api/v2/builder-pages/uuid/${uuid}`
53
+ )
54
+ const data = response.data
55
+ pageInfo.value = {
56
+ id: data.id,
57
+ name: data.name,
58
+ is_published: data.is_published,
59
+ updated_at: data.updated_at,
60
+ }
61
+ } catch (err: unknown) {
62
+ const message = err instanceof Error ? err.message : t('motor-core.errors.something_went_wrong')
63
+ notifyError(t('motor-admin.clients.global_components.footer'), message)
64
+ } finally {
65
+ loadingPage.value = false
66
+ }
67
+ }
68
+
69
+ watch(
70
+ () => props.builderPageUuid,
71
+ (uuid) => {
72
+ if (uuid) {
73
+ void fetchPageInfo(uuid)
74
+ } else {
75
+ pageInfo.value = null
76
+ }
77
+ },
78
+ { immediate: true }
79
+ )
80
+
81
+ // ============================================
82
+ // Helpers
83
+ // ============================================
84
+
85
+ function formatDate(isoString: string): string {
86
+ return new Date(isoString).toLocaleDateString(locale.value, {
87
+ day: 'numeric',
88
+ month: 'long',
89
+ year: 'numeric',
90
+ hour: '2-digit',
91
+ minute: '2-digit',
92
+ })
93
+ }
94
+
95
+ function buildPageName(): string {
96
+ const base = `${props.clientName} - Footer`
97
+ if (props.showLanguageLabel && props.languageName) {
98
+ return `${base} (${props.languageName})`
99
+ }
100
+ return base
101
+ }
102
+
103
+ // ============================================
104
+ // Actions
105
+ // ============================================
106
+
107
+ async function onCreateFooter(): Promise<void> {
108
+ creating.value = true
109
+ try {
110
+ // Step 1: create the empty page. The backend's createBuilderPage service
111
+ // hard-codes page_definition to []; the template is installed via the
112
+ // separate definition endpoint below.
113
+ const response = await client<{ data: BuilderPageResource }>('/api/v2/builder-pages', {
114
+ method: 'POST',
115
+ body: {
116
+ name: buildPageName(),
117
+ client_id: props.clientId,
118
+ language_id: props.languageId,
119
+ type: 'global_component',
120
+ cache_type: 'always',
121
+ ttl: 0,
122
+ is_excluded_from_search_index: false,
123
+ is_excluded_from_search: false,
124
+ is_excluded_from_cookie_banner: false,
125
+ },
126
+ })
127
+ const data = response.data
128
+
129
+ // Step 2: install the template via the definition endpoint.
130
+ try {
131
+ await client(`/api/v2/builder-pages/${data.id}/definition`, {
132
+ method: 'PUT',
133
+ body: {
134
+ id: data.id,
135
+ page_definition: JSON.stringify(createFooterTemplate()),
136
+ is_published: false,
137
+ },
138
+ })
139
+ } catch (defErr: unknown) {
140
+ // Page exists but the template install failed. Surface a warning so the
141
+ // user knows to populate it manually, but still link + navigate so the
142
+ // empty page isn't orphaned.
143
+ const message = defErr instanceof Error ? defErr.message : t('motor-core.errors.update_failed')
144
+ notifyError(t('motor-admin.clients.global_components.footer'), message)
145
+ }
146
+
147
+ emit('linked', data.uuid, data.id)
148
+ success(t('motor-admin.clients.global_components.footer_created'))
149
+ await router.push(`/motor-builder/builder-pages/${data.id}/edit?returnTo=${encodeURIComponent(route.fullPath)}`)
150
+ } catch (err: unknown) {
151
+ const message = err instanceof Error ? err.message : t('motor-core.errors.create_failed')
152
+ notifyError(t('motor-admin.clients.global_components.footer'), message)
153
+ } finally {
154
+ creating.value = false
155
+ }
156
+ }
157
+
158
+ function onEditFooter(): void {
159
+ if (pageInfo.value) {
160
+ router.push(`/motor-builder/builder-pages/${pageInfo.value.id}/edit?returnTo=${encodeURIComponent(route.fullPath)}`)
161
+ }
162
+ }
163
+
164
+ const unlinkConfirmOpen = ref(false)
165
+
166
+ function onUnlinkFooter(): void {
167
+ unlinkConfirmOpen.value = true
168
+ }
169
+
170
+ function executeUnlink(): void {
171
+ unlinkConfirmOpen.value = false
172
+ emit('unlinked')
173
+ }
174
+ </script>
175
+
176
+ <template>
177
+ <div class="flex items-center justify-between gap-4 rounded-lg border border-default px-4 py-3">
178
+ <!-- Left: info -->
179
+ <div class="flex items-center gap-3 min-w-0">
180
+ <UIcon :name="pageInfo ? 'i-lucide-link' : 'i-lucide-unlink'" class="size-5 shrink-0" :class="pageInfo ? 'text-primary' : 'text-dimmed'" />
181
+
182
+ <div class="min-w-0">
183
+ <!-- Label -->
184
+ <div class="text-sm font-medium text-highlighted">
185
+ {{ t('motor-admin.clients.global_components.footer') }}
186
+ <span v-if="showLanguageLabel && languageName" class="text-muted font-normal">
187
+ ({{ languageName }})
188
+ </span>
189
+ </div>
190
+
191
+ <!-- Footer details when page exists -->
192
+ <template v-if="pageInfo">
193
+ <div class="flex items-center flex-wrap gap-2 mt-0.5">
194
+ <span class="text-sm text-muted truncate">{{ pageInfo.name }}</span>
195
+ <UBadge
196
+ v-if="pageInfo.is_published"
197
+ color="success"
198
+ variant="subtle"
199
+ size="xs"
200
+ >
201
+ {{ t('motor-admin.clients.global_components.published') }}
202
+ </UBadge>
203
+ <UBadge
204
+ v-else
205
+ color="warning"
206
+ variant="subtle"
207
+ size="xs"
208
+ >
209
+ {{ t('motor-admin.clients.global_components.draft') }}
210
+ </UBadge>
211
+ <span class="text-xs text-dimmed">{{ formatDate(pageInfo.updated_at) }}</span>
212
+ </div>
213
+ </template>
214
+
215
+ <!-- Loading state -->
216
+ <template v-else-if="loadingPage">
217
+ <div class="flex items-center gap-1.5 mt-0.5">
218
+ <UIcon name="i-lucide-loader-2" class="size-3.5 animate-spin text-muted" />
219
+ <span class="text-sm text-muted">{{ t('motor-core.global.loading') }}</span>
220
+ </div>
221
+ </template>
222
+
223
+ <!-- No footer configured -->
224
+ <template v-else>
225
+ <div class="text-sm text-dimmed mt-0.5">
226
+ {{ t('motor-admin.clients.global_components.no_footer') }}
227
+ </div>
228
+ </template>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Right: actions -->
233
+ <div class="flex items-center gap-2 shrink-0">
234
+ <!-- Footer exists: Edit + Unlink -->
235
+ <template v-if="pageInfo">
236
+ <UButton
237
+ variant="outline"
238
+ size="sm"
239
+ :disabled="disabled"
240
+ @click="onEditFooter"
241
+ >
242
+ {{ t('motor-admin.clients.global_components.edit_footer') }}
243
+ </UButton>
244
+ <UButton
245
+ icon="i-lucide-unlink"
246
+ variant="ghost"
247
+ color="error"
248
+ size="sm"
249
+ :disabled="disabled"
250
+ @click="onUnlinkFooter"
251
+ >
252
+ {{ t('motor-admin.clients.global_components.unlink_footer') }}
253
+ </UButton>
254
+ </template>
255
+
256
+ <!-- No footer: Create -->
257
+ <template v-else-if="!loadingPage">
258
+ <UButton
259
+ variant="outline"
260
+ size="sm"
261
+ :loading="creating"
262
+ :disabled="disabled || creating"
263
+ @click="onCreateFooter"
264
+ >
265
+ {{ t('motor-admin.clients.global_components.create_footer') }}
266
+ </UButton>
267
+ </template>
268
+ </div>
269
+ </div>
270
+
271
+ <!-- Unlink confirmation modal -->
272
+ <UModal v-model:open="unlinkConfirmOpen">
273
+ <template #header>
274
+ {{ t('motor-admin.clients.global_components.unlink_footer') }}
275
+ </template>
276
+ <template #body>
277
+ <div class="space-y-3 text-sm">
278
+ <p>{{ t('motor-admin.clients.global_components.unlink_confirm') }}</p>
279
+ <div
280
+ v-if="pageInfo"
281
+ class="rounded-md bg-[var(--ui-bg-elevated)] px-3 py-2"
282
+ >
283
+ <div class="flex items-center gap-1.5">
284
+ <UIcon
285
+ name="i-lucide-panel-bottom"
286
+ class="size-3.5 shrink-0 text-muted"
287
+ />
288
+ <span class="font-medium">{{ pageInfo.name }}</span>
289
+ </div>
290
+ </div>
291
+ <p class="text-muted">{{ t('motor-admin.clients.global_components.unlink_effect') }}</p>
292
+ </div>
293
+ </template>
294
+ <template #footer>
295
+ <div class="flex justify-end gap-2">
296
+ <UButton
297
+ color="neutral"
298
+ variant="outline"
299
+ @click="unlinkConfirmOpen = false"
300
+ >
301
+ {{ t('motor-core.global.cancel') }}
302
+ </UButton>
303
+ <UButton
304
+ color="error"
305
+ icon="i-lucide-unlink"
306
+ @click="executeUnlink"
307
+ >
308
+ {{ t('motor-admin.clients.global_components.unlink_footer') }}
309
+ </UButton>
310
+ </div>
311
+ </template>
312
+ </UModal>
313
+ </template>
@@ -0,0 +1,65 @@
1
+ <!-- app/components/client/GlobalComponentsSection.vue -->
2
+ <script setup lang="ts">
3
+ const { t } = useI18n()
4
+
5
+ const props = defineProps<{
6
+ clientId: number | string
7
+ clientName: string
8
+ footerMap: Record<string, string> | undefined
9
+ languages: { id: number, name: string }[]
10
+ isMultiLanguage: boolean
11
+ languagesLoading: boolean
12
+ disabled?: boolean
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ 'footer-linked': [languageId: number, uuid: string, pageId: number]
17
+ 'footer-unlinked': [languageId: number]
18
+ }>()
19
+
20
+ const appSettings = useAppSettingsStore()
21
+ const isCompact = computed(() => appSettings.formLayout === 'compact')
22
+
23
+ const cardUi = computed(() => isCompact.value
24
+ ? { root: 'relative flex rounded-lg items-start', container: 'relative flex flex-col p-4 sm:p-6 gap-x-8 gap-y-4', wrapper: 'flex flex-col items-start', body: '' }
25
+ : undefined
26
+ )
27
+
28
+ function getFooterUuid(languageId: number): string | null {
29
+ return props.footerMap?.[String(languageId)] ?? null
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <UPageCard
35
+ :title="t('motor-admin.clients.global_components.title')"
36
+ :ui="cardUi"
37
+ >
38
+ <div v-if="languagesLoading" class="flex items-center gap-2 text-muted py-4">
39
+ <UIcon name="i-lucide-loader-2" class="size-4 animate-spin" />
40
+ <span class="text-sm">{{ t('motor-core.global.loading') }}</span>
41
+ </div>
42
+ <div v-else-if="languages.length === 0" class="text-sm text-muted py-4">
43
+ {{ t('motor-admin.clients.global_components.no_languages') }}
44
+ </div>
45
+ <div v-else :class="isCompact ? 'grid grid-cols-12 gap-x-4 gap-y-3' : 'space-y-4'">
46
+ <div
47
+ v-for="lang in languages"
48
+ :key="lang.id"
49
+ :class="isCompact ? 'col-span-12' : ''"
50
+ >
51
+ <ClientFooterSlotCard
52
+ :client-id="clientId"
53
+ :client-name="clientName"
54
+ :language-id="lang.id"
55
+ :language-name="lang.name"
56
+ :show-language-label="isMultiLanguage"
57
+ :builder-page-uuid="getFooterUuid(lang.id)"
58
+ :disabled="disabled"
59
+ @linked="(uuid: string, pageId: number) => emit('footer-linked', lang.id, uuid, pageId)"
60
+ @unlinked="emit('footer-unlinked', lang.id)"
61
+ />
62
+ </div>
63
+ </div>
64
+ </UPageCard>
65
+ </template>
@@ -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>