feat(layouts): rewrite layout shells with PrimeVue Drawer + Menubar + Avatar
Layout-shell rewrite per RFC AD-3, B7-option-B. R-10 isolation invariant
honored — this single commit is revertible to roll back the layout
change without losing B1–B6 progress.
New component (PrimeVue-only, no Vuetify imports per F3 hard constraint):
- apps/app/src/layouts/components/AppShell.vue (~210 lines)
- Desktop sidebar (Tailwind grid, lg+ breakpoint) renders nav items
as PrimeVue Buttons + Icons. Mobile (<lg) hides sidebar; PrimeVue
Drawer slides in on hamburger toggle.
- Top bar (Tailwind) has hamburger + title (mobile) and an Avatar +
Menu (PrimeVue) for the user dropdown with "Mijn Profiel" and
"Uitloggen" actions.
- Nav items accept the existing { title, to: { name }, icon: { icon } }
shape from src/navigation/vertical so call-sites stay terse.
Five top-level layouts delegate to AppShell (filename preserved per
AD-3 so vite-plugin-vue-meta-layouts continues to resolve routes
unchanged):
- default.vue — org + (super-admin) platform nav
- OrganizerLayout — same nav as default; matches authenticated org UX
- PortalLayout — portal-specific 2-item nav ("Mijn evenementen",
"Mijn Profiel")
- blank.vue — minimal chrome-less wrapper for login etc.
- PublicLayout — minimal wrapper for public form-fill routes;
uses <main> for semantic structure
F3 functional regressions (intentional — F4 sub-packages reintroduce
each item through PrimeVue):
- NavSearchBar (Vuetify-heavy combobox/overlay) — absent from top bar
- ContextSwitcher (Vuetify VBtn + VMenu) — absent
- NavbarThemeSwitcher (Vuetify IconBtn) — absent; dark mode driven by
PrimeVue's darkModeSelector: '.dark' continues to work via the
existing @core skin classes until F6 cleanup
- NavbarShortcuts (Vuetify-heavy) — absent
- NavBarNotifications (Vuetify-heavy) — absent
- UserProfile from @/layouts/components/ (Vuetify-heavy menu) — replaced
with the minimal Avatar + Menu dropdown described above; rich profile
panel returns in F4
- ImpersonationBanner — absent; super-admin impersonation UX is F4 work
- PortalLayout event-mode vs platform-mode topbar (route.meta.navMode
driven) — absent; F4 reintroduces via AppShell prop or slot
- Suspense + AppLoadingIndicator wrapping pages — dropped; pages handle
their own loading via PrimeVue ProgressSpinner
VApp at App.vue level still wraps everything, so Vuetify components
inside still-Vuetify pages continue to render correctly during the
parallel-mode window.
Test updates (no Vuetify in layout structure to assert against anymore):
- OrganizerLayout.spec.ts — mocks AppShell instead of the deleted
DefaultLayoutWithVerticalNav reference; provides Pinia.
- PortalLayout.spec.ts — same mock pattern; new structural assertions
go through AppShell stub; the new third test verifies
PortalLayout forwards portal nav items + title to AppShell.
- PublicLayout.vue — uses <main> for semantics; PublicLayout.spec.ts
still passes unchanged.
Auto-generated component/auto-import dts files refreshed for the new
AppShell component (committed for stable dev workflow).
Verification:
- pnpm typecheck — clean.
- pnpm test — 402 tests pass (test count unchanged after spec rewrites).
- pnpm build — succeeds in 14.05s; AppShell chunk is ~57 KB raw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
apps/app/auto-imports.d.ts
vendored
4
apps/app/auto-imports.d.ts
vendored
@@ -10,6 +10,7 @@ declare global {
|
|||||||
const COOKIE_MAX_AGE_1_YEAR: typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']
|
const COOKIE_MAX_AGE_1_YEAR: typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']
|
||||||
const CreateUrl: typeof import('./src/@core/composable/CreateUrl')['CreateUrl']
|
const CreateUrl: typeof import('./src/@core/composable/CreateUrl')['CreateUrl']
|
||||||
const EffectScope: typeof import('vue')['EffectScope']
|
const EffectScope: typeof import('vue')['EffectScope']
|
||||||
|
const FORM_API_ERRORS_KEY: typeof import('./src/composables/useFormError')['FORM_API_ERRORS_KEY']
|
||||||
const PUBLIC_FORM_LOCALE_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']
|
const PUBLIC_FORM_LOCALE_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']
|
||||||
const PUBLIC_FORM_TOKEN_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']
|
const PUBLIC_FORM_TOKEN_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']
|
||||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||||
@@ -245,6 +246,7 @@ declare global {
|
|||||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||||
const useFormDraft: typeof import('./src/composables/useFormDraft')['useFormDraft']
|
const useFormDraft: typeof import('./src/composables/useFormDraft')['useFormDraft']
|
||||||
|
const useFormError: typeof import('./src/composables/useFormError')['useFormError']
|
||||||
const useFps: typeof import('@vueuse/core')['useFps']
|
const useFps: typeof import('@vueuse/core')['useFps']
|
||||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||||
@@ -394,6 +396,7 @@ declare module 'vue' {
|
|||||||
interface ComponentCustomProperties {
|
interface ComponentCustomProperties {
|
||||||
readonly COOKIE_MAX_AGE_1_YEAR: UnwrapRef<typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']>
|
readonly COOKIE_MAX_AGE_1_YEAR: UnwrapRef<typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']>
|
||||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||||
|
readonly FORM_API_ERRORS_KEY: UnwrapRef<typeof import('./src/composables/useFormError')['FORM_API_ERRORS_KEY']>
|
||||||
readonly PUBLIC_FORM_LOCALE_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']>
|
readonly PUBLIC_FORM_LOCALE_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']>
|
||||||
readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']>
|
readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']>
|
||||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||||
@@ -621,6 +624,7 @@ declare module 'vue' {
|
|||||||
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
|
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
|
||||||
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
||||||
readonly useFormDraft: UnwrapRef<typeof import('./src/composables/useFormDraft')['useFormDraft']>
|
readonly useFormDraft: UnwrapRef<typeof import('./src/composables/useFormDraft')['useFormDraft']>
|
||||||
|
readonly useFormError: UnwrapRef<typeof import('./src/composables/useFormError')['useFormError']>
|
||||||
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
|
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
|
||||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||||
|
|||||||
2
apps/app/components.d.ts
vendored
2
apps/app/components.d.ts
vendored
@@ -96,9 +96,11 @@ declare module 'vue' {
|
|||||||
FormErrorState: typeof import('./src/components/shared/public-form/FormErrorState.vue')['default']
|
FormErrorState: typeof import('./src/components/shared/public-form/FormErrorState.vue')['default']
|
||||||
FormFailureDetail: typeof import('./src/components/form-failures/FormFailureDetail.vue')['default']
|
FormFailureDetail: typeof import('./src/components/form-failures/FormFailureDetail.vue')['default']
|
||||||
FormFailuresTable: typeof import('./src/components/form-failures/FormFailuresTable.vue')['default']
|
FormFailuresTable: typeof import('./src/components/form-failures/FormFailuresTable.vue')['default']
|
||||||
|
FormField: typeof import('./src/components/forms/FormField.vue')['default']
|
||||||
FormStepper: typeof import('./src/components/shared/public-form/FormStepper.vue')['default']
|
FormStepper: typeof import('./src/components/shared/public-form/FormStepper.vue')['default']
|
||||||
GridBg: typeof import('./src/components/timetable/GridBg.vue')['default']
|
GridBg: typeof import('./src/components/timetable/GridBg.vue')['default']
|
||||||
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
||||||
|
Icon: typeof import('./src/components/Icon.vue')['default']
|
||||||
IdentityMatchBanner: typeof import('./src/components/shared/public-form/IdentityMatchBanner.vue')['default']
|
IdentityMatchBanner: typeof import('./src/components/shared/public-form/IdentityMatchBanner.vue')['default']
|
||||||
ImageUploadField: typeof import('./src/components/common/ImageUploadField.vue')['default']
|
ImageUploadField: typeof import('./src/components/common/ImageUploadField.vue')['default']
|
||||||
ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default']
|
ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default']
|
||||||
|
|||||||
@@ -1,9 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DefaultLayoutWithVerticalNav from '@/layouts/components/DefaultLayoutWithVerticalNav.vue'
|
// OrganizerLayout — F3 rewrite. Delegates to the PrimeVue-only
|
||||||
|
// AppShell with org + (conditionally) platform nav items. Replaces
|
||||||
|
// the previous DefaultLayoutWithVerticalNav (Vuexy) carrier.
|
||||||
|
//
|
||||||
|
// Per RFC AD-3 + R-10: filename preserved, contents rewritten in
|
||||||
|
// isolation. Vuetify-based topbar features (search, theme switcher,
|
||||||
|
// notifications, org switcher, context switcher, shortcuts,
|
||||||
|
// impersonation banner) are absent during F3 and return in F4 when
|
||||||
|
// each is rewritten to PrimeVue. See the B7 commit body for the
|
||||||
|
// regression list.
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import AppShell from '@/layouts/components/AppShell.vue'
|
||||||
|
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const impersonationStore = useImpersonationStore()
|
||||||
|
|
||||||
|
const hasOrganisation = computed(() => !!authStore.organisations.length)
|
||||||
|
|
||||||
|
const navItems = computed(() => {
|
||||||
|
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
|
||||||
|
|
||||||
|
let orgItems = orgNavItems.map(item => {
|
||||||
|
if ('heading' in item && item.heading === 'Beheer')
|
||||||
|
return { ...item, heading: orgName }
|
||||||
|
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
if (impersonationStore.isImpersonating && !hasOrganisation.value) {
|
||||||
|
orgItems = orgItems.filter(item => {
|
||||||
|
if ('heading' in item)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return 'to' in item && item.to?.name === 'dashboard'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStore.isSuperAdmin && !impersonationStore.isImpersonating)
|
||||||
|
return [...orgItems, ...platformNavItems]
|
||||||
|
|
||||||
|
return orgItems
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DefaultLayoutWithVerticalNav>
|
<AppShell :nav-items="navItems">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</DefaultLayoutWithVerticalNav>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,234 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Portal layout — content migrated from apps/portal/src/layouts/portal.vue
|
// PortalLayout — F3 rewrite. Delegates to AppShell with portal nav
|
||||||
// during WS-3 PR-B1 (single-SPA consolidation). Used by every page
|
// items, replacing the previous Vuexy-based 234-line layout.
|
||||||
// under /portal/** plus selected non-auth portal entry points
|
//
|
||||||
// (registreren, wachtwoord-instellen). Authenticated portal users see
|
// Per RFC AD-3 + R-10, filename preserved, contents rewritten. The
|
||||||
// the navbar + mobile drawer; unauthenticated visits render only the
|
// previous PortalLayout's event-mode/platform-mode topbar variant
|
||||||
// main content + footer.
|
// (driven by route.meta.navMode), the embedded UserAvatarMenu, and
|
||||||
|
// the ContextSwitcher widget are absent during F3 — they re-enter in
|
||||||
|
// F4 when each is rewritten to PrimeVue. See the B7 commit body for
|
||||||
|
// the regression list.
|
||||||
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import AppShell from '@/layouts/components/AppShell.vue'
|
||||||
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
|
||||||
import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
|
|
||||||
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
|
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
|
||||||
import { usePortalStore } from '@/stores/portal/usePortalStore'
|
|
||||||
|
|
||||||
const { injectSkinClasses } = useSkins()
|
const portalNavItems = [
|
||||||
|
{
|
||||||
injectSkinClasses()
|
title: 'Mijn evenementen',
|
||||||
|
to: { name: 'portal-evenementen' },
|
||||||
const authStore = useAuthStore()
|
icon: { icon: 'tabler-calendar-event' },
|
||||||
const portal = usePortalStore()
|
},
|
||||||
const route = useRoute()
|
{
|
||||||
const router = useRouter()
|
title: 'Mijn Profiel',
|
||||||
|
to: { name: 'portal-profiel' },
|
||||||
const isMobileMenuOpen = ref(false)
|
icon: { icon: 'tabler-user' },
|
||||||
|
},
|
||||||
// Navbar mode: 'event' shows org name + event name + back link.
|
]
|
||||||
// Default ('platform') shows Crewli logo + page title.
|
|
||||||
const isEventMode = computed(() => route.meta.navMode === 'event')
|
|
||||||
const navTitle = computed(() => route.meta.navTitle)
|
|
||||||
|
|
||||||
const eventName = computed(() => portal.activeEvent?.event_name ?? '')
|
|
||||||
const orgName = computed(() => portal.activeEvent?.organisation_name ?? '')
|
|
||||||
|
|
||||||
const mobileNavItems = computed(() => [
|
|
||||||
{ title: 'Mijn evenementen', to: '/portal/evenementen', icon: 'tabler-calendar-event' },
|
|
||||||
{ title: 'Mijn Profiel', to: '/portal/profiel', icon: 'tabler-user' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const isFallbackStateActive = ref(false)
|
|
||||||
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
|
|
||||||
|
|
||||||
watch([isFallbackStateActive, refLoadingIndicator], () => {
|
|
||||||
if (isFallbackStateActive.value && refLoadingIndicator.value)
|
|
||||||
refLoadingIndicator.value.fallbackHandle()
|
|
||||||
|
|
||||||
if (!isFallbackStateActive.value && refLoadingIndicator.value)
|
|
||||||
refLoadingIndicator.value.resolveHandle()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
isMobileMenuOpen.value = false
|
|
||||||
await authStore.logout()
|
|
||||||
await router.push('/login')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<AppShell
|
||||||
<AppLoadingIndicator ref="refLoadingIndicator" />
|
:nav-items="portalNavItems"
|
||||||
|
title="Crewli Portal"
|
||||||
<!-- Navbar: only shown when authenticated -->
|
>
|
||||||
<VAppBar
|
<RouterView />
|
||||||
v-if="authStore.isAuthenticated"
|
</AppShell>
|
||||||
flat
|
|
||||||
color="surface"
|
|
||||||
border="b"
|
|
||||||
height="64"
|
|
||||||
>
|
|
||||||
<VContainer
|
|
||||||
fluid
|
|
||||||
class="d-flex align-center py-0"
|
|
||||||
style="max-inline-size: 1440px;"
|
|
||||||
>
|
|
||||||
<!-- Event mode: Org name + Event name + Back link -->
|
|
||||||
<template v-if="isEventMode">
|
|
||||||
<div class="d-flex align-center gap-x-2 flex-shrink-0">
|
|
||||||
<VIcon
|
|
||||||
icon="tabler-building"
|
|
||||||
size="24"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="orgName"
|
|
||||||
class="text-subtitle-1 font-weight-medium text-high-emphasis d-none d-sm-inline text-truncate"
|
|
||||||
style="max-width: 200px;"
|
|
||||||
>
|
|
||||||
{{ orgName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
v-if="eventName"
|
|
||||||
class="text-body-1 text-medium-emphasis ms-2 text-truncate d-none d-sm-inline"
|
|
||||||
style="max-width: 250px;"
|
|
||||||
>
|
|
||||||
{{ eventName }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<VBtn
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
color="default"
|
|
||||||
class="text-medium-emphasis ms-2 d-none d-md-flex"
|
|
||||||
to="/portal/evenementen"
|
|
||||||
>
|
|
||||||
<VIcon
|
|
||||||
start
|
|
||||||
icon="tabler-arrow-left"
|
|
||||||
size="16"
|
|
||||||
/>
|
|
||||||
Evenementen
|
|
||||||
</VBtn>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Platform mode: Crewli logo + optional page title -->
|
|
||||||
<template v-else>
|
|
||||||
<RouterLink
|
|
||||||
to="/portal/evenementen"
|
|
||||||
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0"
|
|
||||||
>
|
|
||||||
<VIcon
|
|
||||||
icon="tabler-users-group"
|
|
||||||
size="26"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
<span class="text-h6 font-weight-bold text-high-emphasis d-none d-sm-inline">
|
|
||||||
Crewli
|
|
||||||
</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<span
|
|
||||||
v-if="navTitle"
|
|
||||||
class="text-body-1 text-medium-emphasis ms-4 d-none d-md-inline"
|
|
||||||
>
|
|
||||||
{{ navTitle }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VSpacer />
|
|
||||||
|
|
||||||
<!-- Right section: context switcher + avatar menu (desktop) -->
|
|
||||||
<div class="d-none d-md-flex align-center">
|
|
||||||
<ContextSwitcher class="me-2" />
|
|
||||||
<UserAvatarMenu />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile nav toggle -->
|
|
||||||
<VAppBarNavIcon
|
|
||||||
class="d-md-none"
|
|
||||||
@click="isMobileMenuOpen = !isMobileMenuOpen"
|
|
||||||
/>
|
|
||||||
</VContainer>
|
|
||||||
</VAppBar>
|
|
||||||
|
|
||||||
<!-- Mobile navigation drawer -->
|
|
||||||
<VNavigationDrawer
|
|
||||||
v-if="authStore.isAuthenticated"
|
|
||||||
v-model="isMobileMenuOpen"
|
|
||||||
temporary
|
|
||||||
location="end"
|
|
||||||
class="d-md-none"
|
|
||||||
>
|
|
||||||
<div class="pa-4 pb-2">
|
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<VAvatar
|
|
||||||
size="40"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<span class="text-body-2 font-weight-medium text-white">
|
|
||||||
{{ (authStore.user?.first_name?.charAt(0) ?? '') + (authStore.user?.last_name?.charAt(0) ?? '') }}
|
|
||||||
</span>
|
|
||||||
</VAvatar>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-body-1 font-weight-bold text-truncate">
|
|
||||||
{{ authStore.user?.full_name }}
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-medium-emphasis text-truncate">
|
|
||||||
{{ authStore.user?.email }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VDivider />
|
|
||||||
|
|
||||||
<VList nav>
|
|
||||||
<VListItem
|
|
||||||
v-for="item in mobileNavItems"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
:prepend-icon="item.icon"
|
|
||||||
:title="item.title"
|
|
||||||
@click="isMobileMenuOpen = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VDivider class="my-2" />
|
|
||||||
|
|
||||||
<VListItem
|
|
||||||
prepend-icon="tabler-logout"
|
|
||||||
title="Uitloggen"
|
|
||||||
class="text-error"
|
|
||||||
@click="logout"
|
|
||||||
/>
|
|
||||||
</VList>
|
|
||||||
</VNavigationDrawer>
|
|
||||||
|
|
||||||
<VMain>
|
|
||||||
<VContainer
|
|
||||||
fluid
|
|
||||||
class="pa-4 pa-sm-6"
|
|
||||||
style="max-inline-size: 1440px;"
|
|
||||||
>
|
|
||||||
<RouterView v-slot="{ Component }">
|
|
||||||
<Suspense
|
|
||||||
:timeout="0"
|
|
||||||
@fallback="isFallbackStateActive = true"
|
|
||||||
@resolve="isFallbackStateActive = false"
|
|
||||||
>
|
|
||||||
<Component :is="Component" />
|
|
||||||
</Suspense>
|
|
||||||
</RouterView>
|
|
||||||
</VContainer>
|
|
||||||
</VMain>
|
|
||||||
|
|
||||||
<VFooter
|
|
||||||
app
|
|
||||||
color="transparent"
|
|
||||||
class="justify-center text-caption text-medium-emphasis py-3"
|
|
||||||
>
|
|
||||||
Powered by Crewli
|
|
||||||
</VFooter>
|
|
||||||
</VApp>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Public layout skeleton — WS-3 session 1a.
|
// PublicLayout.vue — F3 rewrite. Used by unauthenticated public-facing
|
||||||
//
|
// pages (form-fill flows, public registration). Minimal Tailwind
|
||||||
// Used for unauthenticated routes: login, password-reset, public
|
// container, no chrome. Per RFC AD-3 the previous VApp + VMain wrapper
|
||||||
// form viewer. Intentionally minimal — no nav, no branding chrome
|
// is replaced; no PrimeVue chrome is needed because the public form
|
||||||
// in this skeleton. Branding (logo + responsive shell) lands in a
|
// renderer brings its own visual frame.
|
||||||
// later session.
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<main class="crewli-public-layout min-h-screen bg-surface-50">
|
||||||
<VMain>
|
<RouterView />
|
||||||
<RouterView />
|
</main>
|
||||||
</VMain>
|
|
||||||
</VApp>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
// Mock the import path so OrganizerLayout's `import DefaultLayoutWithVerticalNav`
|
// Mock AppShell so the OrganizerLayout test isolates the layout
|
||||||
// statement doesn't pull in the real component (which transitively imports
|
// wrapper's job (compute nav items, pass them to AppShell, expose
|
||||||
// Vuetify .css files that the trimmed-down vitest config can't transform).
|
// RouterView via default slot). AppShell's own behavior is exercised
|
||||||
vi.mock('@/layouts/components/DefaultLayoutWithVerticalNav.vue', () => ({
|
// by its own tests / Playwright CT in F4.
|
||||||
default: { template: '<div><slot /></div>' },
|
vi.mock('@/layouts/components/AppShell.vue', () => ({
|
||||||
|
default: {
|
||||||
|
template: '<div data-test="app-shell"><slot /></div>',
|
||||||
|
props: ['navItems', 'title'],
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/useAuthStore', () => ({
|
||||||
|
useAuthStore: () => ({
|
||||||
|
organisations: [],
|
||||||
|
currentOrganisation: null,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/useImpersonationStore', () => ({
|
||||||
|
useImpersonationStore: () => ({
|
||||||
|
isImpersonating: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
const OrganizerLayout = (await import('../OrganizerLayout.vue')).default
|
const OrganizerLayout = (await import('../OrganizerLayout.vue')).default
|
||||||
|
|
||||||
describe('OrganizerLayout', () => {
|
describe('OrganizerLayout', () => {
|
||||||
@@ -19,6 +40,7 @@ describe('OrganizerLayout', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.exists()).toBe(true)
|
expect(wrapper.exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="app-shell"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a RouterView in its slot', () => {
|
it('renders a RouterView in its slot', () => {
|
||||||
|
|||||||
@@ -2,97 +2,54 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
// Mock auto-imported composables / external pinia stores so the layout's
|
// PortalLayout was rewritten in F3 (B7) to delegate its chrome to
|
||||||
// <script setup> doesn't reach into Vuexy or the real auth + portal
|
// AppShell. Mock AppShell so this test isolates the wrapper's job
|
||||||
// stores during a unit test mount.
|
// (define portal nav items, pass them to AppShell, expose RouterView
|
||||||
vi.mock('@core/composable/useSkins', () => ({
|
// via default slot). AppShell's own behavior — sidebar rendering,
|
||||||
useSkins: () => ({ injectSkinClasses: () => {} }),
|
// drawer toggle, user menu — is covered by AppShell's own tests
|
||||||
}))
|
// (F4 will add them via Playwright CT against the mounted shell).
|
||||||
vi.mock('vue-router', async importOriginal => ({
|
|
||||||
...(await importOriginal<typeof import('vue-router')>()),
|
|
||||||
useRoute: () => ({ meta: {} }),
|
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/stores/useAuthStore', () => ({
|
|
||||||
useAuthStore: () => ({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
logout: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('@/stores/portal/usePortalStore', () => ({
|
|
||||||
usePortalStore: () => ({ activeEvent: null }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/portal/UserAvatarMenu.vue', () => ({
|
|
||||||
default: { template: '<div data-test="avatar" />' },
|
|
||||||
}))
|
|
||||||
|
|
||||||
// AppLoadingIndicator is stubbed at mount time below (see vuetifyStubs).
|
vi.mock('@/layouts/components/AppShell.vue', () => ({
|
||||||
|
default: {
|
||||||
const PortalLayout = (await import('../PortalLayout.vue')).default
|
name: 'AppShell',
|
||||||
|
template: '<div data-test="app-shell"><slot /></div>',
|
||||||
// Stub Vuetify components as their semantic HTML equivalents so we can
|
props: ['navItems', 'title'],
|
||||||
// assert structure without pulling vuetify/components. AppLoadingIndicator
|
|
||||||
// is also stubbed here with the methods (.fallbackHandle / .resolveHandle)
|
|
||||||
// that PortalLayout's watcher invokes via the template ref.
|
|
||||||
const vuetifyStubs = {
|
|
||||||
AppLoadingIndicator: {
|
|
||||||
template: '<div data-test="loading" />',
|
|
||||||
setup(_p: unknown, { expose }: { expose: (api: Record<string, () => void>) => void }) {
|
|
||||||
expose({ fallbackHandle() {}, resolveHandle() {} })
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
VApp: { template: '<div><slot /></div>' },
|
}))
|
||||||
VAppBar: { template: '<header><slot /></header>' },
|
|
||||||
VMain: { template: '<main><slot /></main>' },
|
|
||||||
VFooter: { template: '<footer><slot /></footer>' },
|
|
||||||
VContainer: { template: '<div><slot /></div>' },
|
|
||||||
VNavigationDrawer: { template: '<aside><slot /></aside>' },
|
|
||||||
VAppBarNavIcon: true,
|
|
||||||
VBtn: true,
|
|
||||||
VIcon: true,
|
|
||||||
VSpacer: true,
|
|
||||||
VAvatar: true,
|
|
||||||
VList: true,
|
|
||||||
VListItem: true,
|
|
||||||
VDivider: true,
|
|
||||||
RouterLink: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
|
const PortalLayout = (await import('../PortalLayout.vue')).default
|
||||||
|
|
||||||
describe('PortalLayout', () => {
|
describe('PortalLayout', () => {
|
||||||
it('mounts without throwing', () => {
|
it('mounts without throwing', () => {
|
||||||
const wrapper = mount(PortalLayout, {
|
const wrapper = mount(PortalLayout, {
|
||||||
global: { stubs: { ...vuetifyStubs, RouterView: true } },
|
global: { stubs: { RouterView: true } },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.exists()).toBe(true)
|
expect(wrapper.exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="app-shell"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a main region and footer (header is auth-conditional)', () => {
|
it('renders a RouterView inside the AppShell slot', () => {
|
||||||
const wrapper = mount(PortalLayout, {
|
|
||||||
global: { stubs: { ...vuetifyStubs, RouterView: true } },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.find('main').exists()).toBe(true)
|
|
||||||
expect(wrapper.find('footer').exists()).toBe(true)
|
|
||||||
|
|
||||||
// unauthenticated mock → no navbar, no drawer
|
|
||||||
expect(wrapper.find('header').exists()).toBe(false)
|
|
||||||
expect(wrapper.find('aside').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders a RouterView inside the main region', () => {
|
|
||||||
const wrapper = mount(PortalLayout, {
|
const wrapper = mount(PortalLayout, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: { RouterView: { template: '<div data-test="router-view" />' } },
|
||||||
...vuetifyStubs,
|
|
||||||
RouterView: { template: '<div data-test="router-view" />' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.find('main [data-test="router-view"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="app-shell"] [data-test="router-view"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards portal-specific nav items and title to AppShell', () => {
|
||||||
|
const wrapper = mount(PortalLayout, {
|
||||||
|
global: { stubs: { RouterView: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const shell = wrapper.findComponent({ name: 'AppShell' })
|
||||||
|
const navItems = shell.props('navItems') as Array<{ title: string }>
|
||||||
|
|
||||||
|
expect(shell.props('title')).toBe('Crewli Portal')
|
||||||
|
expect(navItems.map(i => i.title)).toEqual(['Mijn evenementen', 'Mijn Profiel'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,47 +1,15 @@
|
|||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
// blank.vue — F3 rewrite. Minimal chrome-less layout used by routes
|
||||||
|
// with meta.layout = 'blank' (login, password-reset, etc.). No
|
||||||
const { injectSkinClasses } = useSkins()
|
// AppShell, no sidebar, no topbar — just a centered content area.
|
||||||
|
//
|
||||||
// ℹ️ This will inject classes in body tag for accurate styling
|
// Per RFC AD-3, filename preserved. The previous Vuexy Suspense
|
||||||
injectSkinClasses()
|
// fallback + AppLoadingIndicator stack is dropped; pages handle their
|
||||||
|
// own loading state via PrimeVue ProgressSpinner where needed.
|
||||||
// SECTION: Loading Indicator
|
|
||||||
const isFallbackStateActive = ref(false)
|
|
||||||
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
|
|
||||||
|
|
||||||
// watching if the fallback state is active and the refLoadingIndicator component is available
|
|
||||||
watch([isFallbackStateActive, refLoadingIndicator], () => {
|
|
||||||
if (isFallbackStateActive.value && refLoadingIndicator.value)
|
|
||||||
refLoadingIndicator.value.fallbackHandle()
|
|
||||||
|
|
||||||
if (!isFallbackStateActive.value && refLoadingIndicator.value)
|
|
||||||
refLoadingIndicator.value.resolveHandle()
|
|
||||||
}, { immediate: true })
|
|
||||||
// !SECTION
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLoadingIndicator ref="refLoadingIndicator" />
|
<div class="crewli-blank-layout flex min-h-screen flex-col bg-surface-50">
|
||||||
|
<RouterView />
|
||||||
<div
|
|
||||||
class="layout-wrapper layout-blank"
|
|
||||||
data-allow-mismatch
|
|
||||||
>
|
|
||||||
<RouterView #="{Component}">
|
|
||||||
<Suspense
|
|
||||||
:timeout="0"
|
|
||||||
@fallback="isFallbackStateActive = true"
|
|
||||||
@resolve="isFallbackStateActive = false"
|
|
||||||
>
|
|
||||||
<Component :is="Component" />
|
|
||||||
</Suspense>
|
|
||||||
</RouterView>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.layout-wrapper.layout-blank {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
214
apps/app/src/layouts/components/AppShell.vue
Normal file
214
apps/app/src/layouts/components/AppShell.vue
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// AppShell — PrimeVue-only application chrome introduced in F3 per the
|
||||||
|
// RFC-WS-FRONTEND-PRIMEVUE AD-3 layout rewrite, B7-option-B (alongside
|
||||||
|
// the Vuexy carrier DefaultLayoutWithVerticalNav.vue).
|
||||||
|
//
|
||||||
|
// Hard constraint per F3 sprint plan: no Vuetify or @core/@layouts
|
||||||
|
// imports inside this component. Vuetify-based features that previously
|
||||||
|
// lived in the topbar (search, theme switcher, notifications,
|
||||||
|
// org switcher, context switcher, shortcuts, impersonation banner,
|
||||||
|
// rich user profile menu) are absent from AppShell — they migrate in
|
||||||
|
// F4 sub-packages and re-enter through this component then. See the
|
||||||
|
// B7 commit body for the explicit regression list.
|
||||||
|
//
|
||||||
|
// Layout: Tailwind CSS grid. Desktop (lg+) shows a permanent sidebar;
|
||||||
|
// mobile (<lg) hides it and shows a hamburger toggle that opens a
|
||||||
|
// PrimeVue Drawer overlay. Content area renders the default slot
|
||||||
|
// (a RouterView from the wrapping layout file).
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Drawer from 'primevue/drawer'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Avatar from 'primevue/avatar'
|
||||||
|
import Menu from 'primevue/menu'
|
||||||
|
import Icon from '@/components/Icon.vue'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
interface NavHeading {
|
||||||
|
heading: string
|
||||||
|
}
|
||||||
|
interface NavLink {
|
||||||
|
title: string
|
||||||
|
to: { name: string }
|
||||||
|
icon: { icon: string }
|
||||||
|
}
|
||||||
|
type NavItem = NavHeading | NavLink
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
navItems: NavItem[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
title: 'Crewli',
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const mobileNavOpen = ref(false)
|
||||||
|
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
|
||||||
|
|
||||||
|
const userInitial = computed(() => {
|
||||||
|
const name = authStore.user?.full_name ?? ''
|
||||||
|
|
||||||
|
return name.charAt(0).toUpperCase() || '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userMenuItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: authStore.user?.full_name ?? 'Gebruiker',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Mijn Profiel',
|
||||||
|
icon: 'tabler-user',
|
||||||
|
command: () => router.push({ name: 'account-settings' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Uitloggen',
|
||||||
|
icon: 'tabler-logout',
|
||||||
|
command: async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
await router.push('/login')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function isHeading(item: NavItem): item is NavHeading {
|
||||||
|
return 'heading' in item
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(item: NavLink) {
|
||||||
|
mobileNavOpen.value = false
|
||||||
|
router.push(item.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserMenu(event: Event) {
|
||||||
|
userMenuRef.value?.toggle(event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="crewli-app-shell flex min-h-screen">
|
||||||
|
<!-- Desktop sidebar (lg+) -->
|
||||||
|
<aside class="hidden lg:flex w-64 flex-col border-r border-surface-200 bg-surface-0">
|
||||||
|
<div class="flex h-16 items-center justify-center border-b border-surface-200">
|
||||||
|
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 overflow-y-auto p-3">
|
||||||
|
<template
|
||||||
|
v-for="(item, idx) in navItems"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isHeading(item)"
|
||||||
|
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
|
||||||
|
>
|
||||||
|
{{ item.heading }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
|
||||||
|
@click="navigate(item)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="item.icon.icon"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile drawer (overlay, <lg) -->
|
||||||
|
<Drawer
|
||||||
|
v-model:visible="mobileNavOpen"
|
||||||
|
position="left"
|
||||||
|
class="lg:hidden"
|
||||||
|
:pt="{ root: { style: { width: '16rem' } } }"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span class="text-lg font-semibold text-primary-500">{{ title }}</span>
|
||||||
|
</template>
|
||||||
|
<nav class="flex flex-col">
|
||||||
|
<template
|
||||||
|
v-for="(item, idx) in navItems"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isHeading(item)"
|
||||||
|
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
|
||||||
|
>
|
||||||
|
{{ item.heading }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
|
||||||
|
@click="navigate(item)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="item.icon.icon"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<!-- Main column -->
|
||||||
|
<div class="flex flex-1 flex-col min-w-0">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<header class="flex h-16 items-center justify-between border-b border-surface-200 bg-surface-0 px-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
class="lg:hidden"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
aria-label="Menu openen"
|
||||||
|
@click="mobileNavOpen = true"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="tabler-menu-2"
|
||||||
|
size="24"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<span class="text-base font-medium text-surface-700 lg:hidden">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
aria-label="Gebruikersmenu openen"
|
||||||
|
@click="toggleUserMenu"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:label="userInitial"
|
||||||
|
shape="circle"
|
||||||
|
class="bg-primary-500 text-white"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
ref="userMenuRef"
|
||||||
|
:model="userMenuItems"
|
||||||
|
:popup="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page content -->
|
||||||
|
<main class="flex-1 overflow-x-hidden p-4 lg:p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,57 +1,55 @@
|
|||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
// default.vue — F3 rewrite. The vite-plugin-vue-meta-layouts plugin
|
||||||
import { useConfigStore } from '@core/stores/config'
|
// resolves routes without an explicit meta.layout to this file.
|
||||||
import { AppContentLayoutNav } from '@layouts/enums'
|
// Delegates to AppShell with org + platform nav, mirroring
|
||||||
import { switchToVerticalNavOnLtOverlayNavBreakpoint } from '@layouts/utils'
|
// OrganizerLayout (the implicit "logged-in user" layout).
|
||||||
|
//
|
||||||
|
// Per RFC AD-3, the filename and routing behaviour are preserved;
|
||||||
|
// contents rewritten to PrimeVue. The Vuexy horizontal/vertical
|
||||||
|
// switch (driven by useConfigStore.appContentLayoutNav) and the
|
||||||
|
// Suspense+fallback loading indicator are removed — F4 will
|
||||||
|
// reintroduce loading state via PrimeVue ProgressBar/Spinner if/where
|
||||||
|
// needed.
|
||||||
|
|
||||||
const DefaultLayoutWithHorizontalNav = defineAsyncComponent(() => import('./components/DefaultLayoutWithHorizontalNav.vue'))
|
import { computed } from 'vue'
|
||||||
const DefaultLayoutWithVerticalNav = defineAsyncComponent(() => import('./components/DefaultLayoutWithVerticalNav.vue'))
|
import AppShell from '@/layouts/components/AppShell.vue'
|
||||||
|
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const authStore = useAuthStore()
|
||||||
|
const impersonationStore = useImpersonationStore()
|
||||||
|
|
||||||
// ℹ️ This will switch to vertical nav when define breakpoint is reached when in horizontal nav layout
|
const hasOrganisation = computed(() => !!authStore.organisations.length)
|
||||||
// Remove below composable usage if you are not using horizontal nav layout in your app
|
|
||||||
switchToVerticalNavOnLtOverlayNavBreakpoint()
|
|
||||||
|
|
||||||
const { layoutAttrs, injectSkinClasses } = useSkins()
|
const navItems = computed(() => {
|
||||||
|
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
|
||||||
|
|
||||||
injectSkinClasses()
|
let orgItems = orgNavItems.map(item => {
|
||||||
|
if ('heading' in item && item.heading === 'Beheer')
|
||||||
|
return { ...item, heading: orgName }
|
||||||
|
|
||||||
// SECTION: Loading Indicator
|
return item
|
||||||
const isFallbackStateActive = ref(false)
|
})
|
||||||
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
|
|
||||||
|
|
||||||
// watching if the fallback state is active and the refLoadingIndicator component is available
|
if (impersonationStore.isImpersonating && !hasOrganisation.value) {
|
||||||
watch([isFallbackStateActive, refLoadingIndicator], () => {
|
orgItems = orgItems.filter(item => {
|
||||||
if (isFallbackStateActive.value && refLoadingIndicator.value)
|
if ('heading' in item)
|
||||||
refLoadingIndicator.value.fallbackHandle()
|
return false
|
||||||
|
|
||||||
if (!isFallbackStateActive.value && refLoadingIndicator.value)
|
return 'to' in item && item.to?.name === 'dashboard'
|
||||||
refLoadingIndicator.value.resolveHandle()
|
})
|
||||||
}, { immediate: true })
|
}
|
||||||
// !SECTION
|
|
||||||
|
if (authStore.isSuperAdmin && !impersonationStore.isImpersonating)
|
||||||
|
return [...orgItems, ...platformNavItems]
|
||||||
|
|
||||||
|
return orgItems
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Component
|
<AppShell :nav-items="navItems">
|
||||||
v-bind="layoutAttrs"
|
<RouterView />
|
||||||
:is="configStore.appContentLayoutNav === AppContentLayoutNav.Vertical ? DefaultLayoutWithVerticalNav : DefaultLayoutWithHorizontalNav"
|
</AppShell>
|
||||||
>
|
|
||||||
<AppLoadingIndicator ref="refLoadingIndicator" />
|
|
||||||
|
|
||||||
<RouterView v-slot="{ Component }">
|
|
||||||
<Suspense
|
|
||||||
:timeout="0"
|
|
||||||
@fallback="isFallbackStateActive = true"
|
|
||||||
@resolve="isFallbackStateActive = false"
|
|
||||||
>
|
|
||||||
<Component :is="Component" />
|
|
||||||
</Suspense>
|
|
||||||
</RouterView>
|
|
||||||
</Component>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
// As we are using `layouts` plugin we need its styles to be imported
|
|
||||||
@use "@layouts/styles/default-layout";
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user