@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.
- package/app/assets/css/v-onboarding.css +64 -0
- package/app/components/OnboardingStep.vue +42 -0
- package/app/components/UsersOnboarding.vue +84 -0
- package/app/components/client/FooterSlotCard.vue +313 -0
- package/app/components/client/GlobalComponentsSection.vue +65 -0
- package/app/components/dashboard/DashboardActivity.vue +71 -0
- package/app/components/dashboard/DashboardActivityItem.vue +96 -0
- package/app/components/dashboard/DashboardAnnouncementModal.vue +327 -0
- package/app/components/dashboard/DashboardAnnouncements.vue +93 -0
- package/app/components/dashboard/DashboardOnboarding.vue +285 -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/components/form/inputs/CategoryTreePicker.vue +265 -109
- package/app/components/form/inputs/EntityConfigurationsPanel.vue +235 -0
- package/app/composables/useClientFormExtensions.ts +89 -0
- package/app/composables/useClientLanguages.ts +81 -0
- package/app/composables/useDashboardData.ts +169 -0
- package/app/composables/useOnboardingState.ts +151 -0
- package/app/data/footerTemplate.ts +283 -0
- package/app/lang/de/motor-admin/ai_system_prompts.json +1 -0
- package/app/lang/de/motor-admin/categories.json +1 -0
- package/app/lang/de/motor-admin/category_trees.json +2 -1
- package/app/lang/de/motor-admin/clients.json +17 -1
- package/app/lang/de/motor-admin/config_variables.json +1 -0
- package/app/lang/de/motor-admin/dashboard.json +83 -0
- package/app/lang/de/motor-admin/domains.json +6 -1
- package/app/lang/de/motor-admin/email_templates.json +1 -0
- package/app/lang/de/motor-admin/entity_configurations.json +12 -0
- package/app/lang/de/motor-admin/languages.json +1 -0
- package/app/lang/de/motor-admin/onboarding.json +60 -0
- package/app/lang/de/motor-admin/permissions.json +1 -0
- package/app/lang/de/motor-admin/roles.json +1 -0
- package/app/lang/de/motor-admin/users.json +1 -0
- package/app/lang/en/motor-admin/ai_system_prompts.json +1 -0
- package/app/lang/en/motor-admin/categories.json +1 -0
- package/app/lang/en/motor-admin/category_trees.json +2 -1
- package/app/lang/en/motor-admin/clients.json +17 -1
- package/app/lang/en/motor-admin/config_variables.json +1 -0
- package/app/lang/en/motor-admin/dashboard.json +83 -0
- package/app/lang/en/motor-admin/domains.json +6 -1
- package/app/lang/en/motor-admin/email_templates.json +1 -0
- package/app/lang/en/motor-admin/entity_configurations.json +12 -0
- package/app/lang/en/motor-admin/languages.json +1 -0
- package/app/lang/en/motor-admin/onboarding.json +60 -0
- package/app/lang/en/motor-admin/permissions.json +1 -0
- package/app/lang/en/motor-admin/roles.json +1 -0
- package/app/lang/en/motor-admin/users.json +1 -0
- package/app/pages/index.vue +119 -22
- package/app/pages/login.vue +6 -0
- package/app/pages/motor-admin/ai-system-prompts/[id]/edit.vue +4 -4
- package/app/pages/motor-admin/category-trees/[id]/categories/[categoryId]/edit.vue +4 -3
- package/app/pages/motor-admin/category-trees/[id]/edit.vue +4 -4
- package/app/pages/motor-admin/clients/[id]/edit.vue +146 -6
- package/app/pages/motor-admin/clients/create.vue +34 -2
- package/app/pages/motor-admin/config-variables/[id]/edit.vue +4 -4
- package/app/pages/motor-admin/domains/[id]/edit.vue +18 -5
- package/app/pages/motor-admin/email-templates/[id]/edit.vue +4 -4
- package/app/pages/motor-admin/email-templates/index.vue +36 -25
- package/app/pages/motor-admin/languages/[id]/edit.vue +17 -4
- package/app/pages/motor-admin/languages/create.vue +13 -0
- package/app/pages/motor-admin/permission-groups/[id]/edit.vue +4 -4
- package/app/pages/motor-admin/roles/[id]/edit.vue +4 -4
- package/app/pages/motor-admin/roles/create.vue +4 -1
- package/app/pages/motor-admin/users/[id]/edit.vue +4 -3
- package/app/pages/motor-admin/users/index.vue +1 -0
- package/app/pages/profile.vue +47 -1
- package/app/pages/search.vue +13 -3
- package/app/types/generated/form-meta.ts +24 -20
- package/app/types/generated/grid-meta.ts +5 -3
- package/nuxt.config.ts +15 -1
- package/package.json +6 -2
- 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>
|