chore(f3.5): AppShell mockup parity — sidebar, topbar, plugin fixes #26
@@ -17,12 +17,13 @@
|
|||||||
// (a RouterView from the wrapping layout file).
|
// (a RouterView from the wrapping layout file).
|
||||||
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import Drawer from 'primevue/drawer'
|
import Drawer from 'primevue/drawer'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Avatar from 'primevue/avatar'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import Menu from 'primevue/menu'
|
|
||||||
import Icon from '@/components/Icon.vue'
|
import Icon from '@/components/Icon.vue'
|
||||||
|
import SidebarHeader from '@/layouts/components/SidebarHeader.vue'
|
||||||
|
import SidebarUserCard from '@/layouts/components/SidebarUserCard.vue'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
interface NavHeading {
|
interface NavHeading {
|
||||||
@@ -40,42 +41,23 @@ interface Props {
|
|||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
title: 'Crewli',
|
title: 'Crewli',
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const mobileNavOpen = ref(false)
|
const mobileNavOpen = ref(false)
|
||||||
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
|
|
||||||
|
|
||||||
const userInitial = computed(() => {
|
// Tailwind's lg breakpoint, mirrored in script so Vue can own the
|
||||||
const name = authStore.user?.full_name ?? ''
|
// visibility of PrimeVue elements that would otherwise lose a CSS
|
||||||
|
// specificity duel to .p-button / .p-drawer / etc. See the wrapper
|
||||||
return name.charAt(0).toUpperCase() || '?'
|
// `<div class="lg:hidden">` around the topbar mobile cluster and the
|
||||||
})
|
// `v-if="!isLg"` on the Drawer.
|
||||||
|
const isLg = useMediaQuery('(min-width: 1024px)')
|
||||||
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 {
|
function isHeading(item: NavItem): item is NavHeading {
|
||||||
return 'heading' in item
|
return 'heading' in item
|
||||||
@@ -86,26 +68,68 @@ function navigate(item: NavLink) {
|
|||||||
router.push(item.to)
|
router.push(item.to)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUserMenu(event: Event) {
|
// Breadcrumb: "Organisation / Page title". Page title resolves from
|
||||||
userMenuRef.value?.toggle(event)
|
// route.meta.title → matching navItems entry → humanized route name.
|
||||||
|
// Org name is omitted on portal / pre-org users (currentOrganisation
|
||||||
|
// is null) so the breadcrumb reads as just the page title there.
|
||||||
|
const orgName = computed(() => authStore.currentOrganisation?.name ?? '')
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const metaTitle = route.meta.title
|
||||||
|
if (typeof metaTitle === 'string' && metaTitle.length > 0)
|
||||||
|
return metaTitle
|
||||||
|
|
||||||
|
const match = props.navItems.find(
|
||||||
|
(item): item is NavLink => 'to' in item && item.to.name === route.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (match)
|
||||||
|
return match.title
|
||||||
|
|
||||||
|
const raw = String(route.name ?? '')
|
||||||
|
if (!raw)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return raw.charAt(0).toUpperCase() + raw.slice(1).replace(/-/g, ' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
function onNotificationsClick() {
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Notificaties',
|
||||||
|
detail: 'Notificaties komen binnenkort beschikbaar.',
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(docs-url): https://docs.crewli.app currently serves with a TLS
|
||||||
|
// cert that does not cover the host (ERR_TLS_CERT_ALTNAME_INVALID),
|
||||||
|
// so a browser hit shows a security warning instead of the docs.
|
||||||
|
// When the cert is fixed, switch this handler to:
|
||||||
|
// window.open('https://docs.crewli.app', '_blank', 'noopener,noreferrer')
|
||||||
|
function onHelpClick() {
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Help',
|
||||||
|
detail: 'Documentatie komt binnenkort beschikbaar.',
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="crewli-app-shell flex min-h-screen">
|
<div class="crewli-app-shell flex min-h-screen">
|
||||||
<!-- Desktop sidebar (lg+) -->
|
<!-- Desktop sidebar (lg+) -->
|
||||||
<aside class="hidden lg:flex w-64 flex-col border-r border-surface-200 bg-surface-0">
|
<aside class="hidden lg:flex w-72 flex-col border-r border-surface-200 bg-surface-0">
|
||||||
<div class="flex h-16 items-center justify-center border-b border-surface-200">
|
<SidebarHeader :title="title" />
|
||||||
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
|
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
</div>
|
|
||||||
<nav class="flex-1 overflow-y-auto p-3">
|
|
||||||
<template
|
<template
|
||||||
v-for="(item, idx) in navItems"
|
v-for="(item, idx) in navItems"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isHeading(item)"
|
v-if="isHeading(item)"
|
||||||
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
|
class="mt-6 mb-1 px-3 text-[11px] font-medium uppercase tracking-widest text-surface-400"
|
||||||
>
|
>
|
||||||
{{ item.heading }}
|
{{ item.heading }}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,26 +147,36 @@ function toggleUserMenu(event: Event) {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
<SidebarUserCard />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Mobile drawer (overlay, <lg) -->
|
<!--
|
||||||
|
Mobile drawer (overlay, <lg). v-if (not lg:hidden class) because
|
||||||
|
PrimeVue Drawer teleports to body, so a wrapping div or class
|
||||||
|
on the Drawer root can't beat .p-drawer's display rule in the
|
||||||
|
cascade — Vue must simply not render the component on lg+.
|
||||||
|
-->
|
||||||
<Drawer
|
<Drawer
|
||||||
|
v-if="!isLg"
|
||||||
v-model:visible="mobileNavOpen"
|
v-model:visible="mobileNavOpen"
|
||||||
position="left"
|
position="left"
|
||||||
class="lg:hidden"
|
:pt="{
|
||||||
:pt="{ root: { style: { width: '16rem' } } }"
|
root: { style: { width: '18rem' } },
|
||||||
|
header: { class: 'p-0' },
|
||||||
|
content: { class: 'flex flex-col flex-1 min-h-0 p-0' },
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="text-lg font-semibold text-primary-500">{{ title }}</span>
|
<SidebarHeader :title="title" />
|
||||||
</template>
|
</template>
|
||||||
<nav class="flex flex-col">
|
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<template
|
<template
|
||||||
v-for="(item, idx) in navItems"
|
v-for="(item, idx) in navItems"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isHeading(item)"
|
v-if="isHeading(item)"
|
||||||
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
|
class="mt-6 mb-1 px-3 text-[11px] font-medium uppercase tracking-widest text-surface-400"
|
||||||
>
|
>
|
||||||
{{ item.heading }}
|
{{ item.heading }}
|
||||||
</div>
|
</div>
|
||||||
@@ -160,6 +194,7 @@ function toggleUserMenu(event: Event) {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
<SidebarUserCard />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<!-- Main column -->
|
<!-- Main column -->
|
||||||
@@ -167,41 +202,70 @@ function toggleUserMenu(event: Event) {
|
|||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<header class="flex h-16 items-center justify-between border-b border-surface-200 bg-surface-0 px-4">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<!--
|
||||||
class="lg:hidden"
|
Mobile cluster: visibility owned by this plain DIV. The
|
||||||
severity="secondary"
|
PrimeVue Button alone with lg:hidden loses to
|
||||||
text
|
.p-button { display: inline-flex } in the cascade — same
|
||||||
rounded
|
specificity, PrimeVue's stylesheet loads later.
|
||||||
aria-label="Menu openen"
|
-->
|
||||||
@click="mobileNavOpen = true"
|
<div class="flex items-center gap-2 lg:hidden">
|
||||||
|
<Button
|
||||||
|
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">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
class="hidden items-center gap-2 text-sm lg:flex"
|
||||||
|
aria-label="Kruimelpad"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="orgName"
|
||||||
|
class="text-surface-500"
|
||||||
|
>{{ orgName }}</span>
|
||||||
<Icon
|
<Icon
|
||||||
name="tabler-menu-2"
|
v-if="orgName && pageTitle"
|
||||||
size="24"
|
name="tabler-chevron-right"
|
||||||
|
size="14"
|
||||||
|
class="text-surface-400"
|
||||||
/>
|
/>
|
||||||
</Button>
|
<span class="font-medium text-surface-900">{{ pageTitle }}</span>
|
||||||
<span class="text-base font-medium text-surface-700 lg:hidden">{{ title }}</span>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
rounded
|
rounded
|
||||||
aria-label="Gebruikersmenu openen"
|
aria-label="Notificaties"
|
||||||
@click="toggleUserMenu"
|
@click="onNotificationsClick"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Icon
|
||||||
:label="userInitial"
|
name="tabler-bell"
|
||||||
shape="circle"
|
size="22"
|
||||||
class="bg-primary-500 text-white"
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
aria-label="Help"
|
||||||
|
@click="onHelpClick"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="tabler-help"
|
||||||
|
size="22"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Menu
|
|
||||||
ref="userMenuRef"
|
|
||||||
:model="userMenuItems"
|
|
||||||
:popup="true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
104
apps/app/src/layouts/components/SidebarHeader.vue
Normal file
104
apps/app/src/layouts/components/SidebarHeader.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// SidebarHeader — top-of-sidebar org-switcher card introduced in F3.5
|
||||||
|
// per the AppShell mockup-parity sprint. PrimeVue-only rewrite of the
|
||||||
|
// legacy Vuetify OrganisationSwitcher (apps/app/src/components/layout/
|
||||||
|
// OrganisationSwitcher.vue), which cannot be reused inside AppShell
|
||||||
|
// per the R-10 layout-shell-isolation invariant.
|
||||||
|
//
|
||||||
|
// When the auth store has no active organisation (e.g. portal users,
|
||||||
|
// or a fresh super_admin without an org), the component degrades to a
|
||||||
|
// plain title block — the same visual the F3 AppShell shipped with.
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import Menu from 'primevue/menu'
|
||||||
|
import Avatar from 'primevue/avatar'
|
||||||
|
import Icon from '@/components/Icon.vue'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
title: 'Crewli',
|
||||||
|
})
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const menuRef = ref<InstanceType<typeof Menu> | null>(null)
|
||||||
|
|
||||||
|
const currentOrg = computed(() => authStore.currentOrganisation)
|
||||||
|
const organisations = computed(() => authStore.organisations)
|
||||||
|
const hasSwitcher = computed(() => !!currentOrg.value)
|
||||||
|
const hasMultiple = computed(() => organisations.value.length > 1)
|
||||||
|
|
||||||
|
const orgInitials = computed(() => {
|
||||||
|
const name = currentOrg.value?.name ?? ''
|
||||||
|
const parts = name.split(/\s+/).filter(Boolean).slice(0, 2)
|
||||||
|
const initials = parts.map(w => w[0]?.toUpperCase() ?? '').join('')
|
||||||
|
|
||||||
|
return initials || '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plan tier placeholder. Backend `Organisation` resource does not yet
|
||||||
|
// expose a plan field (see types/auth.ts:17-27); 'Pro' is hardcoded
|
||||||
|
// per Bert's A6.1 decision until that field lands.
|
||||||
|
const planLabel = 'Pro'
|
||||||
|
|
||||||
|
const menuItems = computed(() =>
|
||||||
|
organisations.value.map(org => ({
|
||||||
|
label: org.name,
|
||||||
|
command: () => authStore.setActiveOrganisation(org.id),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleMenu(event: Event) {
|
||||||
|
if (!hasMultiple.value)
|
||||||
|
return
|
||||||
|
menuRef.value?.toggle(event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-b border-surface-200">
|
||||||
|
<button
|
||||||
|
v-if="hasSwitcher"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-3 text-left transition enabled:hover:bg-surface-100 disabled:cursor-default"
|
||||||
|
:disabled="!hasMultiple"
|
||||||
|
:aria-label="hasMultiple ? 'Organisatie wisselen' : currentOrg?.name"
|
||||||
|
:aria-haspopup="hasMultiple ? 'menu' : undefined"
|
||||||
|
@click="toggleMenu"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:label="orgInitials"
|
||||||
|
shape="circle"
|
||||||
|
class="shrink-0 bg-primary-500 text-white"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span class="truncate text-sm font-semibold text-surface-900">
|
||||||
|
{{ currentOrg?.name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-surface-500">{{ planLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
v-if="hasMultiple"
|
||||||
|
name="tabler-chevron-down"
|
||||||
|
size="18"
|
||||||
|
class="shrink-0 text-surface-500"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-16 items-center justify-center px-3"
|
||||||
|
>
|
||||||
|
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
ref="menuRef"
|
||||||
|
:model="menuItems"
|
||||||
|
:popup="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
113
apps/app/src/layouts/components/SidebarUserCard.vue
Normal file
113
apps/app/src/layouts/components/SidebarUserCard.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// SidebarUserCard — bottom-of-sidebar user identity + menu, introduced
|
||||||
|
// in F3.5 per Bert's A6.5 decision. Consolidates the user menu in a
|
||||||
|
// single location (sidebar bottom) and removes the topbar avatar
|
||||||
|
// dropdown, eliminating two paths to the same Logout / Profile actions.
|
||||||
|
//
|
||||||
|
// The PrimeVue Menu in popup mode auto-flips above the trigger when
|
||||||
|
// the panel would overflow the viewport bottom (per PrimeVue v4's
|
||||||
|
// DomHandler.absolutePosition logic). The card sits flush against the
|
||||||
|
// bottom of the sidebar, so the dropdown reliably opens upward.
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Menu from 'primevue/menu'
|
||||||
|
import Avatar from 'primevue/avatar'
|
||||||
|
import Icon from '@/components/Icon.vue'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const menuRef = ref<InstanceType<typeof Menu> | null>(null)
|
||||||
|
|
||||||
|
const userInitial = computed(() => {
|
||||||
|
const name = authStore.user?.full_name ?? ''
|
||||||
|
|
||||||
|
return name.charAt(0).toUpperCase() || '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userName = computed(() => authStore.user?.full_name ?? 'Gebruiker')
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: 'Super Admin',
|
||||||
|
org_admin: 'Beheerder',
|
||||||
|
org_member: 'Lid',
|
||||||
|
event_manager: 'Eventmanager',
|
||||||
|
staff_coordinator: 'Staf coördinator',
|
||||||
|
volunteer_coordinator: 'Vrijwilligers coördinator',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabel = computed(() => {
|
||||||
|
const orgRole = authStore.currentOrganisation?.role
|
||||||
|
if (orgRole)
|
||||||
|
return roleLabels[orgRole] ?? orgRole
|
||||||
|
if (authStore.isSuperAdmin)
|
||||||
|
return 'Super Admin'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: userName.value,
|
||||||
|
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 toggleMenu(event: Event) {
|
||||||
|
menuRef.value?.toggle(event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-t border-surface-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-3 text-left transition hover:bg-surface-100"
|
||||||
|
aria-label="Gebruikersmenu openen"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
@click="toggleMenu"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:label="userInitial"
|
||||||
|
shape="circle"
|
||||||
|
class="shrink-0 bg-primary-500 text-white"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span class="truncate text-sm font-semibold text-surface-900">
|
||||||
|
{{ userName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="roleLabel"
|
||||||
|
class="truncate text-xs text-surface-500"
|
||||||
|
>{{ roleLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
name="tabler-chevron-up"
|
||||||
|
size="18"
|
||||||
|
class="shrink-0 text-surface-500"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
ref="menuRef"
|
||||||
|
:model="menuItems"
|
||||||
|
:popup="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Iconify-Tabler runtime data — side-effect import must run before any
|
||||||
|
// component renders an Icon, so the @iconify/vue runtime resolves
|
||||||
|
// names locally instead of falling back to the api.iconify.design CDN
|
||||||
|
// (blocked by our CSP). See plugins/iconify.ts for the bootstrap.
|
||||||
|
import '@/plugins/iconify'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||||
import { queryClientConfig } from '@/lib/query-client'
|
import { queryClientConfig } from '@/lib/query-client'
|
||||||
@@ -6,7 +12,7 @@ import { router } from '@/plugins/1.router'
|
|||||||
|
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue'
|
||||||
import { registerPlugins } from '@core/utils/plugins'
|
import { registerPlugins } from '@core/utils/plugins'
|
||||||
import installPrimeVue from '@/plugins/primevue'
|
import { installPrimeVue } from '@/plugins/primevue'
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
import '@styles/tailwind.css'
|
import '@styles/tailwind.css'
|
||||||
|
|||||||
24
apps/app/src/plugins/iconify.ts
Normal file
24
apps/app/src/plugins/iconify.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Iconify-Tabler runtime bootstrap — introduced in F3.5 to make
|
||||||
|
// @iconify/vue's <Icon> render real SVG paths from local data instead
|
||||||
|
// of fetching individual icons from https://api.iconify.design/ at
|
||||||
|
// runtime. The CSP blocks that origin, so the previous behaviour was
|
||||||
|
// an empty <svg viewBox="0 0 16 16"></svg> for every Tabler icon used
|
||||||
|
// in the PrimeVue side of the parallel-mode app.
|
||||||
|
//
|
||||||
|
// This file is a separate concern from src/plugins/iconify/index.ts +
|
||||||
|
// icons.css, which exists to serve the Vuetify side: Vuexy's @core
|
||||||
|
// `<VIcon icon="tabler-X" />` adapter renders via the i-tabler-* CSS
|
||||||
|
// classes those files inject, and Vuetify never touches the @iconify
|
||||||
|
// /vue runtime. The two systems must coexist until F6 retires
|
||||||
|
// Vuetify, after which the legacy plugins/iconify/ directory can be
|
||||||
|
// deleted alongside it.
|
||||||
|
//
|
||||||
|
// Imported as a side effect at the top of main.ts, so addCollection
|
||||||
|
// runs before any component renders an Icon. The whole Tabler set
|
||||||
|
// (~1.9 MB uncompressed JSON / ~400 KB gzipped) is loaded eagerly;
|
||||||
|
// per-icon imports are a future optimisation, not in F3.5 scope.
|
||||||
|
|
||||||
|
import { addCollection } from '@iconify/vue'
|
||||||
|
import tablerIcons from '@iconify-json/tabler/icons.json'
|
||||||
|
|
||||||
|
addCollection(tablerIcons)
|
||||||
@@ -6,6 +6,16 @@
|
|||||||
// Per RFC-WS-FRONTEND-PRIMEVUE AD-2: darkModeSelector matches Vuexy's
|
// Per RFC-WS-FRONTEND-PRIMEVUE AD-2: darkModeSelector matches Vuexy's
|
||||||
// `.dark` class convention so existing skin-toggle plumbing continues
|
// `.dark` class convention so existing skin-toggle plumbing continues
|
||||||
// to work during the F3–F6 parallel-mode window.
|
// to work during the F3–F6 parallel-mode window.
|
||||||
|
//
|
||||||
|
// Exported as a NAMED function (no `export default`) on purpose: the
|
||||||
|
// Vuexy registerPlugins() helper (src/@core/utils/plugins.ts) globs
|
||||||
|
// plugins/*/index.{ts,js} and invokes the `default` export of each
|
||||||
|
// match. A default export here would cause PrimeVue + its three
|
||||||
|
// services to register twice — once via registerPlugins, once via the
|
||||||
|
// explicit installPrimeVue(app) call in main.ts. The named export
|
||||||
|
// keeps the explicit installer in main.ts as the single registration
|
||||||
|
// site, which is the design intent (decouple PrimeVue from the Vuexy
|
||||||
|
// @core machine so F6 can delete @core/ without affecting PrimeVue).
|
||||||
|
|
||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
@@ -17,7 +27,7 @@ import nl from 'primelocale/nl.json'
|
|||||||
import { CrewliPreset } from './theme'
|
import { CrewliPreset } from './theme'
|
||||||
import { ptDefaults } from './defaults'
|
import { ptDefaults } from './defaults'
|
||||||
|
|
||||||
export default function installPrimeVue(app: App) {
|
export function installPrimeVue(app: App) {
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
preset: CrewliPreset,
|
preset: CrewliPreset,
|
||||||
|
|||||||
Reference in New Issue
Block a user