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

@@ -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>