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:
2026-05-11 01:12:06 +02:00
parent f5a9e491ce
commit 4391550140
10 changed files with 415 additions and 407 deletions

View File

@@ -1,234 +1,35 @@
<script setup lang="ts">
// Portal layout — content migrated from apps/portal/src/layouts/portal.vue
// during WS-3 PR-B1 (single-SPA consolidation). Used by every page
// under /portal/** plus selected non-auth portal entry points
// (registreren, wachtwoord-instellen). Authenticated portal users see
// the navbar + mobile drawer; unauthenticated visits render only the
// main content + footer.
// PortalLayout — F3 rewrite. Delegates to AppShell with portal nav
// items, replacing the previous Vuexy-based 234-line layout.
//
// Per RFC AD-3 + R-10, filename preserved, contents rewritten. The
// previous PortalLayout's event-mode/platform-mode topbar variant
// (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 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'
import AppShell from '@/layouts/components/AppShell.vue'
const { injectSkinClasses } = useSkins()
injectSkinClasses()
const authStore = useAuthStore()
const portal = usePortalStore()
const route = useRoute()
const router = useRouter()
const isMobileMenuOpen = ref(false)
// 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')
}
const portalNavItems = [
{
title: 'Mijn evenementen',
to: { name: 'portal-evenementen' },
icon: { icon: 'tabler-calendar-event' },
},
{
title: 'Mijn Profiel',
to: { name: 'portal-profiel' },
icon: { icon: 'tabler-user' },
},
]
</script>
<template>
<VApp>
<AppLoadingIndicator ref="refLoadingIndicator" />
<!-- Navbar: only shown when authenticated -->
<VAppBar
v-if="authStore.isAuthenticated"
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>
<AppShell
:nav-items="portalNavItems"
title="Crewli Portal"
>
<RouterView />
</AppShell>
</template>