@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,235 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ configurableType: string
4
+ configurableId: number | string
5
+ }>()
6
+
7
+ const { t } = useI18n()
8
+ const client = useSanctumClient()
9
+ const { success: notifySuccess, error: notifyError } = useNotify()
10
+
11
+ interface ConfigVariable {
12
+ id: number
13
+ package: string
14
+ group: string
15
+ name: string
16
+ value: string
17
+ }
18
+
19
+ interface EntityConfiguration {
20
+ id: number
21
+ configurable_type: string
22
+ configurable_id: number
23
+ config_variable_id: number
24
+ config_variable?: ConfigVariable
25
+ value: string | null
26
+ }
27
+
28
+ const configurations = ref<EntityConfiguration[]>([])
29
+ const configVariables = ref<ConfigVariable[]>([])
30
+ const loading = ref(false)
31
+ const saving = ref(false)
32
+ const removing = ref<number | null>(null)
33
+
34
+ const newConfigVariableId = ref<number | null>(null)
35
+ const newValue = ref('')
36
+
37
+ async function loadConfigurations() {
38
+ loading.value = true
39
+ try {
40
+ const response = await client<{ data: EntityConfiguration[] }>('/api/v2/entity-configurations', {
41
+ query: {
42
+ configurable_type: props.configurableType,
43
+ configurable_id: props.configurableId,
44
+ per_page: 100
45
+ }
46
+ })
47
+ configurations.value = response.data ?? []
48
+ } catch {
49
+ configurations.value = []
50
+ } finally {
51
+ loading.value = false
52
+ }
53
+ }
54
+
55
+ async function loadConfigVariables() {
56
+ try {
57
+ const response = await client<{ data: ConfigVariable[] }>('/api/v2/config-variables', {
58
+ query: { per_page: 200 }
59
+ })
60
+ configVariables.value = response.data ?? []
61
+ } catch {
62
+ configVariables.value = []
63
+ }
64
+ }
65
+
66
+ const availableConfigVariables = computed(() => {
67
+ const usedIds = new Set(configurations.value.map(c => c.config_variable_id))
68
+ return configVariables.value
69
+ .filter(v => !usedIds.has(v.id))
70
+ .map(v => ({
71
+ label: `${v.package}.${v.group}.${v.name}`,
72
+ value: v.id
73
+ }))
74
+ })
75
+
76
+ const canAdd = computed(() => newConfigVariableId.value !== null)
77
+
78
+ async function addConfiguration() {
79
+ if (!canAdd.value) return
80
+ saving.value = true
81
+ try {
82
+ await client('/api/v2/entity-configurations', {
83
+ method: 'POST',
84
+ body: {
85
+ configurable_type: props.configurableType,
86
+ configurable_id: props.configurableId,
87
+ config_variable_id: newConfigVariableId.value,
88
+ value: newValue.value || null
89
+ }
90
+ })
91
+ newConfigVariableId.value = null
92
+ newValue.value = ''
93
+ await loadConfigurations()
94
+ notifySuccess(
95
+ t('motor-admin.entity_configurations.title'),
96
+ t('motor-admin.entity_configurations.saved')
97
+ )
98
+ } catch (e) {
99
+ notifyError(
100
+ t('motor-admin.entity_configurations.title'),
101
+ (e as Error).message
102
+ )
103
+ } finally {
104
+ saving.value = false
105
+ }
106
+ }
107
+
108
+ async function removeConfiguration(config: EntityConfiguration) {
109
+ removing.value = config.id
110
+ try {
111
+ await client(`/api/v2/entity-configurations/${config.id}`, {
112
+ method: 'DELETE'
113
+ })
114
+ await loadConfigurations()
115
+ notifySuccess(
116
+ t('motor-admin.entity_configurations.title'),
117
+ t('motor-admin.entity_configurations.removed')
118
+ )
119
+ } catch (e) {
120
+ notifyError(
121
+ t('motor-admin.entity_configurations.title'),
122
+ (e as Error).message
123
+ )
124
+ } finally {
125
+ removing.value = null
126
+ }
127
+ }
128
+
129
+ function formatVariableName(cv: ConfigVariable | undefined): string {
130
+ if (!cv) return '—'
131
+ return `${cv.package}.${cv.group}.${cv.name}`
132
+ }
133
+
134
+ onMounted(() => {
135
+ loadConfigurations()
136
+ loadConfigVariables()
137
+ })
138
+ </script>
139
+
140
+ <template>
141
+ <UCard>
142
+ <template #header>
143
+ <div class="flex items-center justify-between">
144
+ <h3 class="text-base font-semibold">
145
+ {{ t('motor-admin.entity_configurations.title') }}
146
+ </h3>
147
+ <span
148
+ v-if="configurations.length > 0"
149
+ class="text-xs text-[var(--ui-text-muted)]"
150
+ >
151
+ {{ configurations.length }}
152
+ </span>
153
+ </div>
154
+ </template>
155
+
156
+ <div
157
+ v-if="loading"
158
+ class="flex items-center justify-center py-6"
159
+ >
160
+ <UIcon
161
+ name="i-lucide-loader-2"
162
+ class="size-5 animate-spin text-[var(--ui-text-muted)]"
163
+ />
164
+ </div>
165
+
166
+ <div v-else>
167
+ <!-- Existing configurations -->
168
+ <div
169
+ v-if="configurations.length > 0"
170
+ class="mb-4 space-y-2"
171
+ >
172
+ <div
173
+ v-for="config in configurations"
174
+ :key="config.id"
175
+ class="flex items-center gap-4 py-2.5 px-3 rounded-lg ring-1 ring-[var(--ui-border)] group"
176
+ >
177
+ <UIcon
178
+ name="i-lucide-link"
179
+ class="size-3.5 text-emerald-500 shrink-0"
180
+ />
181
+ <div class="min-w-0 flex-1">
182
+ <p class="text-sm font-medium truncate">
183
+ {{ formatVariableName(config.config_variable) }}
184
+ </p>
185
+ </div>
186
+ <div class="shrink-0 max-w-xs">
187
+ <span class="text-sm text-[var(--ui-text-muted)] font-mono truncate">
188
+ {{ config.value ?? '—' }}
189
+ </span>
190
+ </div>
191
+ <UButton
192
+ icon="i-lucide-unlink"
193
+ variant="ghost"
194
+ color="error"
195
+ size="xs"
196
+ :loading="removing === config.id"
197
+ class="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
198
+ @click="removeConfiguration(config)"
199
+ />
200
+ </div>
201
+ </div>
202
+
203
+ <p
204
+ v-else
205
+ class="text-sm text-[var(--ui-text-muted)] mb-4"
206
+ >
207
+ {{ t('motor-admin.entity_configurations.no_configurations') }}
208
+ </p>
209
+
210
+ <!-- Add new configuration -->
211
+ <div class="flex items-start gap-3">
212
+ <USelect
213
+ v-model="newConfigVariableId"
214
+ :items="availableConfigVariables"
215
+ :placeholder="t('motor-admin.entity_configurations.select_variable')"
216
+ class="flex-1"
217
+ />
218
+ <UInput
219
+ v-model="newValue"
220
+ :placeholder="t('motor-admin.entity_configurations.value')"
221
+ class="flex-1"
222
+ @keydown.enter.prevent="addConfiguration"
223
+ />
224
+ <UButton
225
+ icon="i-lucide-plus"
226
+ variant="outline"
227
+ :disabled="!canAdd"
228
+ :loading="saving"
229
+ class="shrink-0"
230
+ @click="addConfiguration"
231
+ />
232
+ </div>
233
+ </div>
234
+ </UCard>
235
+ </template>
@@ -0,0 +1,89 @@
1
+ // app/composables/useClientFormExtensions.ts
2
+ import type { Component, Ref } from 'vue'
3
+
4
+ export interface ClientFormExtension {
5
+ key: string
6
+ order: number
7
+ component: Component
8
+ }
9
+
10
+ const registry: ClientFormExtension[] = reactive([])
11
+ const validityState = reactive<Record<string, boolean>>({})
12
+
13
+ const callbackRefs = new Map<string, {
14
+ validate: Ref<() => boolean | Promise<boolean>>
15
+ getSubmitData: Ref<() => Record<string, unknown>>
16
+ }>()
17
+
18
+ function ensureCallbackRefs(key: string) {
19
+ if (!callbackRefs.has(key)) {
20
+ callbackRefs.set(key, {
21
+ validate: ref(() => true),
22
+ getSubmitData: ref(() => ({})),
23
+ })
24
+ }
25
+ return callbackRefs.get(key)!
26
+ }
27
+
28
+ export function useClientFormExtensions() {
29
+ function register(ext: ClientFormExtension) {
30
+ if (registry.some(e => e.key === ext.key)) return
31
+ registry.push(ext)
32
+ registry.sort((a, b) => a.order - b.order)
33
+ validityState[ext.key] = false
34
+ ensureCallbackRefs(ext.key)
35
+ }
36
+
37
+ function setValidity(key: string, isValid: boolean) {
38
+ validityState[key] = isValid
39
+ }
40
+
41
+ async function validateAll(): Promise<boolean> {
42
+ for (const ext of registry) {
43
+ const refs = callbackRefs.get(ext.key)
44
+ if (refs) {
45
+ const result = await refs.validate.value()
46
+ if (!result) return false
47
+ }
48
+ }
49
+ return true
50
+ }
51
+
52
+ function getAllSubmitData(): Record<string, unknown> {
53
+ let merged: Record<string, unknown> = {}
54
+ for (const ext of registry) {
55
+ const refs = callbackRefs.get(ext.key)
56
+ if (refs) {
57
+ merged = { ...merged, ...refs.getSubmitData.value() }
58
+ }
59
+ }
60
+ return merged
61
+ }
62
+
63
+ const extensions = computed(() => [...registry])
64
+
65
+ const allExtensionsValid = computed(() =>
66
+ registry.length === 0 || registry.every(ext => validityState[ext.key] === true)
67
+ )
68
+
69
+ return { extensions, register, setValidity, allExtensionsValid, validateAll, getAllSubmitData }
70
+ }
71
+
72
+ export function useExtensionCallbacks(key: string) {
73
+ const refs = ensureCallbackRefs(key)
74
+
75
+ function setValidate(fn: () => boolean | Promise<boolean>) {
76
+ refs.validate.value = fn
77
+ }
78
+
79
+ function setGetSubmitData(fn: () => Record<string, unknown>) {
80
+ refs.getSubmitData.value = fn
81
+ }
82
+
83
+ onUnmounted(() => {
84
+ refs.validate.value = () => true
85
+ refs.getSubmitData.value = () => ({})
86
+ })
87
+
88
+ return { setValidate, setGetSubmitData }
89
+ }
@@ -0,0 +1,81 @@
1
+ import type { Ref, ComputedRef } from 'vue'
2
+ import type { components } from '@motor-cms/ui-core/app/types/generated/api'
3
+
4
+ type NavigationTreeResource = components['schemas']['NavigationTreeResource']
5
+ type LanguageResource = components['schemas']['LanguageResource']
6
+
7
+ interface PaginatedResponse<T> {
8
+ data: T[]
9
+ }
10
+
11
+ export interface ClientLanguage {
12
+ id: number
13
+ name: string
14
+ }
15
+
16
+ export interface UseClientLanguagesReturn {
17
+ languages: Ref<ClientLanguage[]>
18
+ isMultiLanguage: ComputedRef<boolean>
19
+ loading: Ref<boolean>
20
+ }
21
+
22
+ export function useClientLanguages(clientId: Ref<string | number>): UseClientLanguagesReturn {
23
+ const client = useSanctumClient()
24
+
25
+ const languages = ref<ClientLanguage[]>([])
26
+ const loading = ref(false)
27
+
28
+ const isMultiLanguage = computed(() => languages.value.length > 1)
29
+
30
+ watch(
31
+ clientId,
32
+ async (id) => {
33
+ if (!id) {
34
+ languages.value = []
35
+ return
36
+ }
37
+
38
+ loading.value = true
39
+ try {
40
+ const treesResponse = await client<PaginatedResponse<NavigationTreeResource>>(
41
+ `/api/v2/navigation-trees?filter[client_id]=${id}&per_page=100`
42
+ )
43
+
44
+ const trees = treesResponse.data ?? []
45
+
46
+ const distinctLanguageIds = [...new Set(
47
+ trees
48
+ .map((tree) => Number(tree.language_id))
49
+ .filter((langId) => !isNaN(langId) && langId > 0)
50
+ )]
51
+
52
+ if (distinctLanguageIds.length === 0) {
53
+ languages.value = []
54
+ return
55
+ }
56
+
57
+ const languagesResponse = await client<PaginatedResponse<LanguageResource>>(
58
+ '/api/v2/languages?per_page=100'
59
+ )
60
+
61
+ const allLanguages = languagesResponse.data ?? []
62
+
63
+ languages.value = allLanguages
64
+ .filter((lang) => distinctLanguageIds.includes(lang.id))
65
+ .map((lang) => ({ id: lang.id, name: lang.english_name }))
66
+ .sort((a, b) => a.id - b.id)
67
+ } catch {
68
+ languages.value = []
69
+ } finally {
70
+ loading.value = false
71
+ }
72
+ },
73
+ { immediate: true }
74
+ )
75
+
76
+ return {
77
+ languages,
78
+ isMultiLanguage,
79
+ loading,
80
+ }
81
+ }
@@ -0,0 +1,169 @@
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
+ starts_at: string | null
41
+ created_at: string
42
+ }
43
+
44
+ interface ActivityMeta {
45
+ current_page: number
46
+ last_page: number
47
+ per_page: number
48
+ total: number
49
+ }
50
+
51
+ interface DashboardResponse {
52
+ data: {
53
+ stats: DashboardStats
54
+ activity: ActivityItem[]
55
+ activity_meta: ActivityMeta
56
+ publishing_queue: PublishingQueueItem[]
57
+ announcements: AnnouncementItem[]
58
+ }
59
+ }
60
+
61
+ const NOTIFIED_STORAGE_KEY = 'dashboard-announcements-notified'
62
+
63
+ function getNotifiedIds(): Set<number> {
64
+ try {
65
+ const raw = localStorage.getItem(NOTIFIED_STORAGE_KEY)
66
+ return new Set(raw ? JSON.parse(raw) : [])
67
+ } catch {
68
+ return new Set()
69
+ }
70
+ }
71
+
72
+ function persistNotifiedIds(ids: Set<number>) {
73
+ localStorage.setItem(NOTIFIED_STORAGE_KEY, JSON.stringify([...ids]))
74
+ }
75
+
76
+ export function useDashboardData() {
77
+ const client = useSanctumClient()
78
+ const { notify } = useNotify()
79
+
80
+ const stats = ref<DashboardStats>({
81
+ pages_total: 0,
82
+ pages_draft: 0,
83
+ pages_published: 0,
84
+ pages_scheduled: 0,
85
+ media_total: 0,
86
+ navigation_trees: 0,
87
+ })
88
+ const activity = ref<ActivityItem[]>([])
89
+ const activityMeta = ref<ActivityMeta>({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
90
+ const activityLoadingMore = ref(false)
91
+ const publishingQueue = ref<PublishingQueueItem[]>([])
92
+ const announcements = ref<AnnouncementItem[]>([])
93
+ const loading = ref(true)
94
+
95
+ const hasMoreActivity = computed(() => activityMeta.value.current_page < activityMeta.value.last_page)
96
+
97
+ async function refresh() {
98
+ loading.value = true
99
+ try {
100
+ const response = await client<DashboardResponse>('/api/v2/dashboard')
101
+ stats.value = response.data.stats
102
+ activity.value = response.data.activity
103
+ activityMeta.value = response.data.activity_meta
104
+ publishingQueue.value = response.data.publishing_queue
105
+ announcements.value = response.data.announcements
106
+
107
+ const notified = getNotifiedIds()
108
+ for (const a of response.data.announcements) {
109
+ if (!notified.has(a.id)) {
110
+ notify({
111
+ title: a.title,
112
+ description: a.body ?? undefined,
113
+ color: a.type === 'error' ? 'error' : a.type === 'warning' ? 'warning' : 'info',
114
+ icon: a.type === 'error' ? 'i-lucide-alert-circle' : a.type === 'warning' ? 'i-lucide-alert-triangle' : 'i-lucide-megaphone',
115
+ })
116
+ notified.add(a.id)
117
+ }
118
+ }
119
+ persistNotifiedIds(notified)
120
+ } finally {
121
+ loading.value = false
122
+ }
123
+ }
124
+
125
+ async function loadMoreActivity() {
126
+ if (!hasMoreActivity.value || activityLoadingMore.value) return
127
+ activityLoadingMore.value = true
128
+ try {
129
+ const nextPage = activityMeta.value.current_page + 1
130
+ const response = await client<DashboardResponse>(`/api/v2/dashboard?activity_page=${nextPage}`)
131
+ activity.value.push(...response.data.activity)
132
+ activityMeta.value = response.data.activity_meta
133
+ } finally {
134
+ activityLoadingMore.value = false
135
+ }
136
+ }
137
+
138
+ async function dismissAnnouncement(id: number) {
139
+ await client(`/api/v2/dashboard/announcements/${id}/dismiss`, {
140
+ method: 'POST',
141
+ })
142
+ announcements.value = announcements.value.filter(a => a.id !== id)
143
+ }
144
+
145
+ async function createAnnouncement(data: Record<string, unknown>) {
146
+ await client('/api/v2/dashboard/announcements', {
147
+ method: 'POST',
148
+ body: data,
149
+ })
150
+ await refresh()
151
+ }
152
+
153
+ refresh().catch(() => {})
154
+
155
+ return {
156
+ stats,
157
+ activity,
158
+ activityMeta,
159
+ activityLoadingMore,
160
+ hasMoreActivity,
161
+ publishingQueue,
162
+ announcements,
163
+ loading,
164
+ refresh,
165
+ loadMoreActivity,
166
+ dismissAnnouncement,
167
+ createAnnouncement,
168
+ }
169
+ }