@nuxt-customer-portal/ui 0.8.9 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/app/app.config.ts +1 -0
- package/app/components/AppHeader.vue +39 -4
- package/app/components/AppUserMenu.vue +1 -1
- package/app/components/NotificationsSlideover.vue +3 -1
- package/app/components/PortalSidebarNavigation.vue +206 -0
- package/app/composables/useInvitationManagement.ts +4 -3
- package/app/composables/useModuleNavigation.ts +1 -0
- package/app/composables/useNavigationLinks.ts +5 -12
- package/app/layouts/auth.vue +57 -11
- package/app/layouts/default.vue +28 -15
- package/app/utils/auth-background.ts +11 -0
- package/app/utils/navigation-search.ts +35 -0
- package/package.json +2 -2
- package/portal.manifest.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# @nuxt-customer-portal/ui
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1235d0f: Let portal administrators upload separate desktop and mobile authentication backgrounds in Appearance settings. Add responsive image selection, single-image fallback, preview and removal controls, validated image uploads, and English/Dutch descriptions. Keep existing settings compatible. Position authentication forms near the top on mobile while preserving desktop positioning, with or without a background image.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 1235d0f: Redirect the portal home route to `/dashboard` instead of showing the public landing page. Preserve dashboard authentication, onboarding, and saved homepage content. Send portal logout directly to `/login` instead of the home route, and remove the home back button from authentication pages.
|
|
12
|
+
|
|
13
|
+
Use a neutral welcome heading on the sign-in page in English and Dutch so it also welcomes first-time visitors.
|
|
14
|
+
|
|
15
|
+
Show translated email and password prompts instead of technical validation errors when sign-in fields are empty or missing.
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [f115496]
|
|
18
|
+
- @nuxt-customer-portal/core@0.10.0
|
|
19
|
+
|
|
20
|
+
## 0.9.1
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- @nuxt-customer-portal/core@0.9.1
|
|
25
|
+
|
|
26
|
+
## 0.9.0
|
|
27
|
+
|
|
28
|
+
### Minor Changes
|
|
29
|
+
|
|
30
|
+
- 6696fd0: Add an opt-in unified sidebar with nested module navigation and keyboard-accessible collapsed flyouts. Include module subpages in global search, preserve standalone destinations, and place sidebar collapse controls in the footer. Enable the unified layout for SaaS Portal.
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- Updated dependencies [936f0a2]
|
|
35
|
+
- Updated dependencies [6696fd0]
|
|
36
|
+
- @nuxt-customer-portal/core@0.9.0
|
|
37
|
+
|
|
3
38
|
## 0.8.9
|
|
4
39
|
|
|
5
40
|
### Patch Changes
|
package/app/app.config.ts
CHANGED
|
@@ -5,11 +5,13 @@ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
|
5
5
|
const props = withDefaults(
|
|
6
6
|
defineProps<{
|
|
7
7
|
showNavigation?: boolean
|
|
8
|
+
sidebarNavigation?: boolean
|
|
8
9
|
brandName?: string
|
|
9
10
|
brandTagline?: string
|
|
10
11
|
}>(),
|
|
11
12
|
{
|
|
12
13
|
showNavigation: true,
|
|
14
|
+
sidebarNavigation: false,
|
|
13
15
|
brandName: 'Nuxt Customer Portal',
|
|
14
16
|
brandTagline: 'Customer workspace'
|
|
15
17
|
}
|
|
@@ -66,7 +68,21 @@ const { currentUser, isAuthenticated, currentSession, myOrganizations, activeOrg
|
|
|
66
68
|
|
|
67
69
|
const showOrgSwitcherModal = ref(false)
|
|
68
70
|
const searchOpen = ref(false)
|
|
71
|
+
// Register on the header so search remains available while its modal is unmounted.
|
|
72
|
+
defineShortcuts({
|
|
73
|
+
meta_k: {
|
|
74
|
+
usingInput: true,
|
|
75
|
+
handler: () => {
|
|
76
|
+
headerMenuOpen.value = false
|
|
77
|
+
searchOpen.value = !searchOpen.value
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
69
81
|
const headerMenuOpen = ref(false)
|
|
82
|
+
const openSearch = () => {
|
|
83
|
+
headerMenuOpen.value = false
|
|
84
|
+
searchOpen.value = true
|
|
85
|
+
}
|
|
70
86
|
|
|
71
87
|
const hasMultipleOrganizations = computed(() => {
|
|
72
88
|
return myOrganizations.value && myOrganizations.value.length > 1
|
|
@@ -254,7 +270,7 @@ const stopImpersonating = async () => {
|
|
|
254
270
|
</div>
|
|
255
271
|
</template>
|
|
256
272
|
|
|
257
|
-
<nav v-if="showNavigation" class="hidden lg:flex flex-1 items-center justify-center gap-6">
|
|
273
|
+
<nav v-if="showNavigation && !sidebarNavigation" class="hidden lg:flex flex-1 items-center justify-center gap-6">
|
|
258
274
|
<template v-for="module in moduleItems" :key="module.id">
|
|
259
275
|
<NuxtLink
|
|
260
276
|
:to="module.to"
|
|
@@ -272,7 +288,15 @@ const stopImpersonating = async () => {
|
|
|
272
288
|
|
|
273
289
|
<template #right>
|
|
274
290
|
<div class="hidden lg:flex items-center gap-3 ml-auto">
|
|
275
|
-
<UButton
|
|
291
|
+
<UButton
|
|
292
|
+
:aria-label="t('menu.search')"
|
|
293
|
+
icon="i-lucide-search"
|
|
294
|
+
color="neutral"
|
|
295
|
+
variant="ghost"
|
|
296
|
+
size="sm"
|
|
297
|
+
square
|
|
298
|
+
@click="openSearch"
|
|
299
|
+
/>
|
|
276
300
|
<ULocaleSelect
|
|
277
301
|
v-if="availableLanguages.length > 1"
|
|
278
302
|
v-model="currentLocale"
|
|
@@ -315,7 +339,7 @@ const stopImpersonating = async () => {
|
|
|
315
339
|
variant="outline"
|
|
316
340
|
size="md"
|
|
317
341
|
class="min-h-11 justify-start"
|
|
318
|
-
@click="
|
|
342
|
+
@click="openSearch"
|
|
319
343
|
/>
|
|
320
344
|
<ULocaleSelect
|
|
321
345
|
v-if="availableLanguages.length > 1"
|
|
@@ -328,7 +352,13 @@ const stopImpersonating = async () => {
|
|
|
328
352
|
</div>
|
|
329
353
|
</div>
|
|
330
354
|
|
|
331
|
-
<
|
|
355
|
+
<PortalSidebarNavigation
|
|
356
|
+
v-if="showNavigation && sidebarNavigation"
|
|
357
|
+
:collapsed="false"
|
|
358
|
+
class="p-4"
|
|
359
|
+
@navigate="headerMenuOpen = false"
|
|
360
|
+
/>
|
|
361
|
+
<nav v-else-if="showNavigation" :aria-label="t('menu.moduleNavigation')" class="space-y-5 p-5 pt-4">
|
|
332
362
|
<section v-for="module in moduleNavigationGroups" :key="module.id" class="space-y-1">
|
|
333
363
|
<button
|
|
334
364
|
v-if="module.menuItems.length > 1"
|
|
@@ -425,8 +455,13 @@ const stopImpersonating = async () => {
|
|
|
425
455
|
|
|
426
456
|
<!-- Dashboard Search Modal -->
|
|
427
457
|
<UDashboardSearch
|
|
458
|
+
v-if="searchOpen"
|
|
428
459
|
v-model:open="searchOpen"
|
|
460
|
+
shortcut=""
|
|
429
461
|
:groups="searchGroups"
|
|
462
|
+
:title="t('menu.search')"
|
|
463
|
+
:description="t('menu.searchDescription')"
|
|
464
|
+
:placeholder="t('menu.searchPages')"
|
|
430
465
|
:fuse="{
|
|
431
466
|
fuseOptions: {
|
|
432
467
|
ignoreLocation: true,
|
|
@@ -4,7 +4,9 @@ import type { Notification } from '@nuxt-customer-portal/core/app/types'
|
|
|
4
4
|
|
|
5
5
|
const { isNotificationsSlideoverOpen } = useDashboard()
|
|
6
6
|
|
|
7
|
-
const { data: notifications } = await useFetch<Notification[]
|
|
7
|
+
const { data: notifications } = await useFetch<Notification[], import('#app').NuxtError<unknown>, string>(
|
|
8
|
+
'/api/notifications'
|
|
9
|
+
)
|
|
8
10
|
</script>
|
|
9
11
|
|
|
10
12
|
<template>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<!-- Hallmark · component review: P4 H4 E4 S4 R4 V4 -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
const props = defineProps<{ collapsed: boolean }>()
|
|
4
|
+
const emit = defineEmits<{ navigate: [] }>()
|
|
5
|
+
const { t } = useI18n()
|
|
6
|
+
const route = useRoute()
|
|
7
|
+
const mobileOpen = ref(false)
|
|
8
|
+
const { moduleNavigationGroups, activeModuleId } = useModuleNavigation(mobileOpen)
|
|
9
|
+
const { links } = useNavigationLinks(mobileOpen)
|
|
10
|
+
const expanded = ref<string[]>([])
|
|
11
|
+
const flyout = ref('')
|
|
12
|
+
const panel = ref<HTMLElement[]>([])
|
|
13
|
+
let returnFocus: HTMLElement | undefined
|
|
14
|
+
let leaveTimer: ReturnType<typeof setTimeout> | undefined
|
|
15
|
+
|
|
16
|
+
const standalone = computed(() => {
|
|
17
|
+
const destinations = new Set(
|
|
18
|
+
moduleNavigationGroups.value.flatMap((module) => [module.to, ...module.menuItems.map((item) => item.to)])
|
|
19
|
+
)
|
|
20
|
+
return links.value.flat().filter((item) => !destinations.has(String(item.to)))
|
|
21
|
+
})
|
|
22
|
+
watch(
|
|
23
|
+
activeModuleId,
|
|
24
|
+
(id) => {
|
|
25
|
+
if (id && !expanded.value.includes(id)) {
|
|
26
|
+
expanded.value.push(id)
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{ immediate: true }
|
|
30
|
+
)
|
|
31
|
+
watch(
|
|
32
|
+
() => route.fullPath,
|
|
33
|
+
() => {
|
|
34
|
+
flyout.value = ''
|
|
35
|
+
if (activeModuleId.value && !expanded.value.includes(activeModuleId.value)) {
|
|
36
|
+
expanded.value.push(activeModuleId.value)
|
|
37
|
+
}
|
|
38
|
+
emit('navigate')
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
watch(
|
|
42
|
+
() => props.collapsed,
|
|
43
|
+
() => {
|
|
44
|
+
flyout.value = ''
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
function toggle(id: string) {
|
|
48
|
+
expanded.value = expanded.value.includes(id)
|
|
49
|
+
? expanded.value.filter((value) => value !== id)
|
|
50
|
+
: [...expanded.value, id]
|
|
51
|
+
}
|
|
52
|
+
function show(id: string) {
|
|
53
|
+
clearTimeout(leaveTimer)
|
|
54
|
+
flyout.value = id
|
|
55
|
+
}
|
|
56
|
+
function leave() {
|
|
57
|
+
clearTimeout(leaveTimer)
|
|
58
|
+
leaveTimer = setTimeout(() => {
|
|
59
|
+
if (!panel.value.some((element) => element?.contains(document.activeElement))) {
|
|
60
|
+
flyout.value = ''
|
|
61
|
+
}
|
|
62
|
+
}, 180)
|
|
63
|
+
}
|
|
64
|
+
function closeFlyout() {
|
|
65
|
+
flyout.value = ''
|
|
66
|
+
returnFocus?.focus()
|
|
67
|
+
returnFocus = undefined
|
|
68
|
+
}
|
|
69
|
+
async function enter(id: string, event: Event) {
|
|
70
|
+
returnFocus = event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined
|
|
71
|
+
show(id)
|
|
72
|
+
await nextTick()
|
|
73
|
+
panel.value
|
|
74
|
+
.find((element) => element?.dataset.module === id)
|
|
75
|
+
?.querySelector<HTMLElement>('a[href]')
|
|
76
|
+
?.focus()
|
|
77
|
+
}
|
|
78
|
+
onBeforeUnmount(() => clearTimeout(leaveTimer))
|
|
79
|
+
const badgeProps = (badge: (typeof moduleNavigationGroups.value)[number]['badge']) =>
|
|
80
|
+
typeof badge === 'object' ? badge : { label: badge }
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<nav :aria-label="t('menu.moduleNavigation')" class="space-y-1" data-portal-sidebar>
|
|
85
|
+
<div v-for="module in moduleNavigationGroups" :key="module.id">
|
|
86
|
+
<template
|
|
87
|
+
v-if="
|
|
88
|
+
module.menuItems.length > 1 || (module.menuItems.length === 1 && module.menuItems[0]?.label !== module.label)
|
|
89
|
+
"
|
|
90
|
+
>
|
|
91
|
+
<UPopover
|
|
92
|
+
v-if="collapsed"
|
|
93
|
+
:open="flyout === module.id"
|
|
94
|
+
:content="{
|
|
95
|
+
side: 'right',
|
|
96
|
+
align: 'start',
|
|
97
|
+
sideOffset: 12,
|
|
98
|
+
collisionPadding: 12,
|
|
99
|
+
onOpenAutoFocus: (event: Event) => event.preventDefault()
|
|
100
|
+
}"
|
|
101
|
+
:ui="{ content: 'bg-default ring-1 ring-default shadow-lg' }"
|
|
102
|
+
@update:open="flyout = $event ? module.id : ''"
|
|
103
|
+
>
|
|
104
|
+
<template #anchor>
|
|
105
|
+
<UButton
|
|
106
|
+
:icon="module.icon"
|
|
107
|
+
:aria-label="module.label"
|
|
108
|
+
aria-haspopup="dialog"
|
|
109
|
+
:aria-expanded="flyout === module.id"
|
|
110
|
+
:color="module.id === activeModuleId ? 'primary' : 'neutral'"
|
|
111
|
+
variant="ghost"
|
|
112
|
+
class="min-h-11 w-full justify-center rounded-md"
|
|
113
|
+
@click="enter(module.id, $event)"
|
|
114
|
+
@pointerenter="show(module.id)"
|
|
115
|
+
@pointerleave="leave"
|
|
116
|
+
@keydown.down.prevent="enter(module.id, $event)"
|
|
117
|
+
@keydown.right.prevent="enter(module.id, $event)"
|
|
118
|
+
/>
|
|
119
|
+
</template>
|
|
120
|
+
<template #content>
|
|
121
|
+
<div
|
|
122
|
+
ref="panel"
|
|
123
|
+
:data-module="module.id"
|
|
124
|
+
class="w-64 max-w-[calc(100vw-6rem)] p-2"
|
|
125
|
+
@pointerenter="show(module.id)"
|
|
126
|
+
@pointerleave="leave"
|
|
127
|
+
@focusout="leave"
|
|
128
|
+
@keydown.esc.prevent.stop="closeFlyout"
|
|
129
|
+
>
|
|
130
|
+
<div class="px-3 py-2 text-sm font-semibold text-highlighted">{{ module.label }}</div>
|
|
131
|
+
<UNavigationMenu
|
|
132
|
+
:items="module.menuItems"
|
|
133
|
+
orientation="vertical"
|
|
134
|
+
:ui="{ link: 'min-h-11 rounded-md' }"
|
|
135
|
+
@click="flyout = ''"
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</template>
|
|
139
|
+
</UPopover>
|
|
140
|
+
<template v-else>
|
|
141
|
+
<UButton
|
|
142
|
+
:icon="module.icon"
|
|
143
|
+
:color="module.id === activeModuleId ? 'primary' : 'neutral'"
|
|
144
|
+
variant="ghost"
|
|
145
|
+
class="min-h-11 w-full justify-start rounded-md px-3"
|
|
146
|
+
:aria-expanded="expanded.includes(module.id)"
|
|
147
|
+
:aria-controls="`sidebar-module-${module.id}`"
|
|
148
|
+
@click="toggle(module.id)"
|
|
149
|
+
>
|
|
150
|
+
<span class="min-w-0 flex-1 truncate text-left">{{ module.label }}</span>
|
|
151
|
+
<UBadge v-if="module.badge" v-bind="badgeProps(module.badge)" />
|
|
152
|
+
<UIcon
|
|
153
|
+
name="i-lucide-chevron-down"
|
|
154
|
+
class="size-4 shrink-0"
|
|
155
|
+
:class="expanded.includes(module.id) ? 'rotate-180' : ''"
|
|
156
|
+
/>
|
|
157
|
+
</UButton>
|
|
158
|
+
<UNavigationMenu
|
|
159
|
+
v-if="expanded.includes(module.id)"
|
|
160
|
+
:id="`sidebar-module-${module.id}`"
|
|
161
|
+
:items="module.menuItems"
|
|
162
|
+
orientation="vertical"
|
|
163
|
+
class="ml-5 my-1 border-l border-default pl-2"
|
|
164
|
+
:ui="{ link: 'min-h-10 rounded-md' }"
|
|
165
|
+
/>
|
|
166
|
+
</template>
|
|
167
|
+
</template>
|
|
168
|
+
<UTooltip v-else :text="module.label" :disabled="!collapsed" :content="{ side: 'right' }">
|
|
169
|
+
<UButton
|
|
170
|
+
:to="module.menuItems[0]?.to ?? module.to"
|
|
171
|
+
:icon="module.icon"
|
|
172
|
+
:label="collapsed ? undefined : module.label"
|
|
173
|
+
:aria-label="module.label"
|
|
174
|
+
:color="module.id === activeModuleId ? 'primary' : 'neutral'"
|
|
175
|
+
variant="ghost"
|
|
176
|
+
class="min-h-11 w-full rounded-md px-3"
|
|
177
|
+
:class="collapsed ? 'justify-center' : 'justify-start'"
|
|
178
|
+
@click="emit('navigate')"
|
|
179
|
+
>
|
|
180
|
+
<template v-if="!collapsed && (module.menuItems[0]?.badge ?? module.badge)" #trailing>
|
|
181
|
+
<UBadge v-bind="badgeProps(module.menuItems[0]?.badge ?? module.badge)" />
|
|
182
|
+
</template>
|
|
183
|
+
</UButton>
|
|
184
|
+
</UTooltip>
|
|
185
|
+
</div>
|
|
186
|
+
<UTooltip
|
|
187
|
+
v-for="item in standalone"
|
|
188
|
+
:key="String(item.to)"
|
|
189
|
+
:text="item.label"
|
|
190
|
+
:disabled="!collapsed"
|
|
191
|
+
:content="{ side: 'right' }"
|
|
192
|
+
>
|
|
193
|
+
<UButton
|
|
194
|
+
:to="item.to"
|
|
195
|
+
:icon="item.icon"
|
|
196
|
+
:label="collapsed ? undefined : item.label"
|
|
197
|
+
:aria-label="item.label"
|
|
198
|
+
:color="route.path === item.to ? 'primary' : 'neutral'"
|
|
199
|
+
variant="ghost"
|
|
200
|
+
class="min-h-11 w-full rounded-md px-3"
|
|
201
|
+
:class="collapsed ? 'justify-center' : 'justify-start'"
|
|
202
|
+
@click="emit('navigate')"
|
|
203
|
+
/>
|
|
204
|
+
</UTooltip>
|
|
205
|
+
</nav>
|
|
206
|
+
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const useInvitationManagement = () => ({
|
|
2
|
-
changeRole: (endpoint: string, role: string) =>
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
changeRole: (endpoint: string, role: string) =>
|
|
3
|
+
$fetch<unknown, string>(endpoint, { method: 'PATCH', body: { role } }),
|
|
4
|
+
resend: (endpoint: string) => $fetch<unknown, string>(`${endpoint}/resend`, { method: 'POST' }),
|
|
5
|
+
revoke: (endpoint: string) => $fetch<unknown, string>(endpoint, { method: 'PATCH', body: { status: 'canceled' } })
|
|
5
6
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommandPaletteGroup, CommandPaletteItem, NavigationMenuItem } from '@nuxt/ui'
|
|
2
|
+
import { buildNavigationSearchGroups } from '../utils/navigation-search'
|
|
2
3
|
import type { PortalAudience } from '@nuxt-customer-portal/core/shared/types/feature'
|
|
3
4
|
|
|
4
5
|
const hasAudience = (
|
|
@@ -45,6 +46,7 @@ export const useNavigationLinks = (sidebarOpen: Ref<boolean>) => {
|
|
|
45
46
|
const { t } = useI18n()
|
|
46
47
|
const route = useRoute()
|
|
47
48
|
const { navigation } = usePortalFeatures()
|
|
49
|
+
const { moduleNavigationGroups } = useModuleNavigation(sidebarOpen)
|
|
48
50
|
const {
|
|
49
51
|
isAuthenticated,
|
|
50
52
|
isSystemAdmin,
|
|
@@ -81,18 +83,9 @@ export const useNavigationLinks = (sidebarOpen: Ref<boolean>) => {
|
|
|
81
83
|
}))
|
|
82
84
|
])
|
|
83
85
|
|
|
84
|
-
const searchGroups = computed<CommandPaletteGroup<CommandPaletteItem>[]>(() =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
label: t('menu.search'),
|
|
88
|
-
items: visibleItems.value.map((item) => ({
|
|
89
|
-
id: item.id,
|
|
90
|
-
label: t(item.labelKey),
|
|
91
|
-
icon: item.icon,
|
|
92
|
-
to: item.to
|
|
93
|
-
}))
|
|
94
|
-
}
|
|
95
|
-
])
|
|
86
|
+
const searchGroups = computed<CommandPaletteGroup<CommandPaletteItem>[]>(() =>
|
|
87
|
+
buildNavigationSearchGroups(moduleNavigationGroups.value, links.value.flat(), t('menu.search'))
|
|
88
|
+
)
|
|
96
89
|
|
|
97
90
|
return { links, searchGroups }
|
|
98
91
|
}
|
package/app/layouts/auth.vue
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
<!-- eslint-disable vue/multi-word-component-names -->
|
|
2
2
|
<script setup lang="ts">
|
|
3
|
+
import { resolveAuthBackgrounds, type AuthBackgroundBranding } from '../utils/auth-background'
|
|
4
|
+
|
|
3
5
|
const route = useRoute()
|
|
4
6
|
const { t } = useI18n()
|
|
5
7
|
const bootstrap = useState<{ adminExists?: boolean; completed?: boolean } | null>('portal-bootstrap-state', () => null)
|
|
8
|
+
const settings = useState<{ branding?: AuthBackgroundBranding } | null>('portal-runtime-settings', () => null)
|
|
9
|
+
const backgrounds = computed(() => resolveAuthBackgrounds(settings.value?.branding))
|
|
6
10
|
</script>
|
|
7
11
|
|
|
8
12
|
<template>
|
|
9
|
-
<div class="auth-shell
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
variant="subtle"
|
|
16
|
-
class="auth-back-button absolute left-8 top-8 rounded-full z-10"
|
|
17
|
-
/>
|
|
18
|
-
|
|
19
|
-
<UPageCard variant="subtle" class="max-w-sm w-full">
|
|
13
|
+
<div class="auth-shell" :class="{ 'auth-shell--illustrated': backgrounds.desktop }">
|
|
14
|
+
<picture v-if="backgrounds.desktop" class="auth-background" aria-hidden="true">
|
|
15
|
+
<source media="(max-width: 1023px)" :srcset="backgrounds.mobile" />
|
|
16
|
+
<img :src="backgrounds.desktop" alt="" fetchpriority="high" decoding="async" />
|
|
17
|
+
</picture>
|
|
18
|
+
<UPageCard variant="subtle" class="auth-card relative w-full max-w-sm shrink-0">
|
|
20
19
|
<UAlert
|
|
21
20
|
v-if="route.path === '/signup' && bootstrap && !bootstrap.adminExists"
|
|
22
21
|
class="mb-5"
|
|
@@ -30,3 +29,50 @@ const bootstrap = useState<{ adminExists?: boolean; completed?: boolean } | null
|
|
|
30
29
|
</UPageCard>
|
|
31
30
|
</div>
|
|
32
31
|
</template>
|
|
32
|
+
|
|
33
|
+
<style scoped>
|
|
34
|
+
.auth-shell {
|
|
35
|
+
position: relative;
|
|
36
|
+
isolation: isolate;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
min-height: 100dvh;
|
|
41
|
+
padding: max(1.5rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))
|
|
42
|
+
max(1.5rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
|
43
|
+
}
|
|
44
|
+
.auth-background {
|
|
45
|
+
position: absolute;
|
|
46
|
+
inset: 0;
|
|
47
|
+
z-index: 0;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
pointer-events: none;
|
|
50
|
+
}
|
|
51
|
+
.auth-background img {
|
|
52
|
+
width: 100%;
|
|
53
|
+
height: 100%;
|
|
54
|
+
object-fit: cover;
|
|
55
|
+
object-position: center bottom;
|
|
56
|
+
}
|
|
57
|
+
.auth-card {
|
|
58
|
+
z-index: 1;
|
|
59
|
+
}
|
|
60
|
+
.auth-shell--illustrated .auth-card {
|
|
61
|
+
background: var(--ui-bg-elevated);
|
|
62
|
+
box-shadow: 0 1.5rem 4.5rem color-mix(in srgb, var(--ui-text) 12%, transparent);
|
|
63
|
+
}
|
|
64
|
+
@media (max-width: 1023px) {
|
|
65
|
+
.auth-shell {
|
|
66
|
+
align-items: flex-start;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
@media (min-width: 1024px) {
|
|
70
|
+
.auth-shell--illustrated {
|
|
71
|
+
justify-content: flex-start;
|
|
72
|
+
padding-inline: clamp(3rem, 12vw, 12rem);
|
|
73
|
+
}
|
|
74
|
+
.auth-background img {
|
|
75
|
+
object-position: right center;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</style>
|
package/app/layouts/default.vue
CHANGED
|
@@ -5,6 +5,8 @@ const toast = useToast()
|
|
|
5
5
|
const { t } = useI18n()
|
|
6
6
|
const open = ref(false)
|
|
7
7
|
const sidebarCollapsed = ref(false)
|
|
8
|
+
const appConfig = useAppConfig()
|
|
9
|
+
const unifiedNavigation = computed(() => appConfig.portal.sidebarNavigation)
|
|
8
10
|
const showFooter = computed(() => route.meta?.public === true)
|
|
9
11
|
|
|
10
12
|
const { activeModule, activeModuleMenuItems } = useModuleNavigation(open)
|
|
@@ -45,7 +47,7 @@ onMounted(async () => {
|
|
|
45
47
|
<!-- AppHeader - fixed at top -->
|
|
46
48
|
<div class="fixed left-0 right-0 z-50" style="top: var(--portal-top-bar-height, 0px)">
|
|
47
49
|
<div class="mx-auto w-full max-w-[1600px] px-4">
|
|
48
|
-
<AppHeader />
|
|
50
|
+
<AppHeader :sidebar-navigation="unifiedNavigation" />
|
|
49
51
|
</div>
|
|
50
52
|
</div>
|
|
51
53
|
|
|
@@ -65,37 +67,48 @@ onMounted(async () => {
|
|
|
65
67
|
:default-size="18"
|
|
66
68
|
:min-size="18"
|
|
67
69
|
:max-size="24"
|
|
68
|
-
class="min-w-72 bg-elevated/25
|
|
69
|
-
:
|
|
70
|
+
class="min-w-72 bg-elevated/25 data-[collapsed=true]:min-w-16"
|
|
71
|
+
:class="unifiedNavigation ? '' : 'lg:pb-12'"
|
|
72
|
+
:ui="{ footer: 'border-t border-default' }"
|
|
70
73
|
>
|
|
71
74
|
<template #header="{ collapsed }">
|
|
72
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
v-if="activeModule && !collapsed && !unifiedNavigation"
|
|
77
|
+
class="flex min-w-0 items-center gap-2 px-2 text-sm font-semibold"
|
|
78
|
+
>
|
|
73
79
|
<UIcon v-if="activeModule.icon" :name="activeModule.icon" class="size-4 shrink-0 text-primary" />
|
|
74
80
|
<span class="truncate">{{ activeModule.label }}</span>
|
|
75
81
|
</div>
|
|
76
82
|
</template>
|
|
77
|
-
<template #default="{ collapsed
|
|
78
|
-
<div
|
|
79
|
-
<span
|
|
83
|
+
<template #default="{ collapsed }">
|
|
84
|
+
<div v-if="!unifiedNavigation && !collapsed && activeModule" class="flex items-center px-2.5 pt-3 pb-1">
|
|
85
|
+
<span class="text-xs font-semibold uppercase text-muted">
|
|
80
86
|
{{ activeModule.label }}
|
|
81
87
|
</span>
|
|
88
|
+
</div>
|
|
89
|
+
<PortalSidebarNavigation v-if="unifiedNavigation" :collapsed="collapsed" @navigate="open = false" />
|
|
90
|
+
<UNavigationMenu
|
|
91
|
+
v-else
|
|
92
|
+
:collapsed="collapsed"
|
|
93
|
+
:items="activeModuleMenuItems"
|
|
94
|
+
orientation="vertical"
|
|
95
|
+
tooltip
|
|
96
|
+
popover
|
|
97
|
+
/>
|
|
98
|
+
</template>
|
|
99
|
+
<template #footer="{ collapsed, collapse }">
|
|
100
|
+
<div class="flex w-full py-2" :class="collapsed ? 'justify-center' : 'justify-end'">
|
|
82
101
|
<UButton
|
|
83
102
|
:icon="collapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
|
|
84
103
|
color="neutral"
|
|
85
104
|
variant="ghost"
|
|
86
|
-
size="
|
|
105
|
+
size="sm"
|
|
106
|
+
class="min-h-11 min-w-11"
|
|
87
107
|
:aria-label="t(collapsed ? 'common.expandSidebar' : 'common.collapseSidebar')"
|
|
88
108
|
:title="t(collapsed ? 'common.expandSidebar' : 'common.collapseSidebar')"
|
|
89
109
|
@click="collapse(!collapsed)"
|
|
90
110
|
/>
|
|
91
111
|
</div>
|
|
92
|
-
<UNavigationMenu
|
|
93
|
-
:collapsed="collapsed"
|
|
94
|
-
:items="activeModuleMenuItems"
|
|
95
|
-
orientation="vertical"
|
|
96
|
-
tooltip
|
|
97
|
-
popover
|
|
98
|
-
/>
|
|
99
112
|
</template>
|
|
100
113
|
</UDashboardSidebar>
|
|
101
114
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface AuthBackgroundBranding {
|
|
2
|
+
authBackgroundDesktop?: string
|
|
3
|
+
authBackgroundMobile?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function resolveAuthBackgrounds(branding?: AuthBackgroundBranding | null) {
|
|
7
|
+
return {
|
|
8
|
+
desktop: branding?.authBackgroundDesktop || branding?.authBackgroundMobile || '',
|
|
9
|
+
mobile: branding?.authBackgroundMobile || branding?.authBackgroundDesktop || ''
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
interface Destination {
|
|
2
|
+
id?: string
|
|
3
|
+
label?: string
|
|
4
|
+
icon?: string
|
|
5
|
+
to?: unknown
|
|
6
|
+
}
|
|
7
|
+
interface NavigationGroup extends Destination {
|
|
8
|
+
id: string
|
|
9
|
+
menuItems: Destination[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Search the same permission-filtered destinations shown by the module menus. */
|
|
13
|
+
export const buildNavigationSearchGroups = (
|
|
14
|
+
modules: NavigationGroup[],
|
|
15
|
+
standalone: Destination[],
|
|
16
|
+
fallbackLabel: string
|
|
17
|
+
) => {
|
|
18
|
+
const seen = new Set<string>()
|
|
19
|
+
const items = (destinations: Destination[], suffix?: string) =>
|
|
20
|
+
destinations.flatMap((item) => {
|
|
21
|
+
if (typeof item.to !== 'string' || seen.has(item.to)) {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
seen.add(item.to)
|
|
25
|
+
return [{ id: item.to, label: item.label, icon: item.icon, to: item.to, suffix }]
|
|
26
|
+
})
|
|
27
|
+
const groups = modules.map((module) => ({
|
|
28
|
+
id: module.id,
|
|
29
|
+
label: module.label,
|
|
30
|
+
items: items([...module.menuItems, module], module.label)
|
|
31
|
+
}))
|
|
32
|
+
return [...groups, { id: 'navigation', label: fallbackLabel, items: items(standalone) }].filter(
|
|
33
|
+
(group) => group.items.length
|
|
34
|
+
)
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxt-customer-portal/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://nuxt-customer-portal.com",
|
|
6
6
|
"repository": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"remark-parse": "^11.0.0",
|
|
40
40
|
"unified": "^11.0.5",
|
|
41
41
|
"zod": "^4.4.3",
|
|
42
|
-
"@nuxt-customer-portal/core": "^0.
|
|
42
|
+
"@nuxt-customer-portal/core": "^0.10.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"nuxt": "^4.5.1",
|