165 lines
5.1 KiB
Vue
165 lines
5.1 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import AppSidebar from '@/components/AppSidebar.vue'
|
|
import BottomDrawer from '@/components/BottomDrawer.vue'
|
|
import {
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbLink,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
BreadcrumbSeparator,
|
|
} from '@/components/ui/breadcrumb'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
|
|
|
const route = useRoute()
|
|
const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs()
|
|
const drawerBounds = useState('bottomDrawerBounds', () => ({ left: 0, width: 0 }))
|
|
const insetRef = ref<any>(null)
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
const breadcrumbs = computed(() => {
|
|
// If custom breadcrumbs are set by the page, use those
|
|
if (customBreadcrumbs.value.length > 0) {
|
|
return customBreadcrumbs.value
|
|
}
|
|
|
|
// Otherwise, fall back to URL-based breadcrumbs
|
|
const paths = route.path.split('/').filter(Boolean)
|
|
return paths.map((path, index) => ({
|
|
name: path.charAt(0).toUpperCase() + path.slice(1),
|
|
path: '/' + paths.slice(0, index + 1).join('/'),
|
|
isLast: index === paths.length - 1,
|
|
}))
|
|
})
|
|
|
|
const resolveInsetEl = (): HTMLElement | null => {
|
|
const maybeComponent = insetRef.value as any
|
|
if (!maybeComponent) return null
|
|
return maybeComponent.$el ? maybeComponent.$el as HTMLElement : (maybeComponent as HTMLElement)
|
|
}
|
|
|
|
const updateBounds = () => {
|
|
const el = resolveInsetEl()
|
|
if (!el || typeof el.getBoundingClientRect !== 'function') return
|
|
const rect = el.getBoundingClientRect()
|
|
drawerBounds.value = {
|
|
left: rect.left,
|
|
width: rect.width,
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
updateBounds()
|
|
const el = resolveInsetEl()
|
|
if (el && 'ResizeObserver' in window) {
|
|
resizeObserver = new ResizeObserver(updateBounds)
|
|
resizeObserver.observe(el)
|
|
}
|
|
window.addEventListener('resize', updateBounds)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
const el = resolveInsetEl()
|
|
if (resizeObserver && el) {
|
|
resizeObserver.unobserve(el)
|
|
}
|
|
resizeObserver = null
|
|
window.removeEventListener('resize', updateBounds)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<SidebarProvider>
|
|
<AppSidebar />
|
|
<SidebarInset ref="insetRef" class="relative flex flex-col">
|
|
<header
|
|
class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md"
|
|
>
|
|
<div class="flex items-center gap-2 px-4">
|
|
<SidebarTrigger class="-ml-1" />
|
|
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
|
<Breadcrumb>
|
|
<BreadcrumbList>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink as-child>
|
|
<NuxtLink to="/">Home</NuxtLink>
|
|
</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
<template v-if="breadcrumbs.length > 0">
|
|
<BreadcrumbSeparator />
|
|
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage v-if="crumb.isLast">
|
|
{{ crumb.name }}
|
|
</BreadcrumbPage>
|
|
<BreadcrumbLink v-else as-child>
|
|
<NuxtLink :to="crumb.path">{{ crumb.name }}</NuxtLink>
|
|
</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator v-if="!crumb.isLast" />
|
|
</template>
|
|
</template>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main scrollable content -->
|
|
<div class="layout-slot-container flex flex-1 flex-col gap-4 p-4 pt-0 overflow-y-auto">
|
|
<slot />
|
|
</div>
|
|
|
|
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
|
|
<BottomDrawer :bounds="drawerBounds" />
|
|
</SidebarInset>
|
|
</SidebarProvider>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.layout-slot-container {
|
|
position: relative;
|
|
background-color: #ffffff;
|
|
background-image: linear-gradient(to bottom, #DFDBE5 0%, rgba(223, 219, 229, 0) 100%);
|
|
background-size: 100% 150px;
|
|
background-repeat: no-repeat;
|
|
}
|
|
|
|
.layout-slot-container::before,
|
|
.layout-slot-container::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0 auto auto 0;
|
|
width: 100%;
|
|
height: 150px;
|
|
pointer-events: none;
|
|
background-image: linear-gradient(to bottom, rgba(156, 146, 172, 0.55) 0%, rgba(156, 146, 172, 0) 100%);
|
|
-webkit-mask-image: url("~/assets/images/pattern.svg");
|
|
-webkit-mask-repeat: repeat;
|
|
-webkit-mask-size: 600px 600px;
|
|
mask-image: url("~/assets/images/pattern.svg");
|
|
mask-repeat: repeat;
|
|
mask-size: 600px 600px;
|
|
z-index: 0;
|
|
}
|
|
|
|
/* Crisp pattern that fades out */
|
|
.layout-slot-container::before {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Slightly shifted, blurred layer to create a “blur into white” effect */
|
|
.layout-slot-container::after {
|
|
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
|
-webkit-mask-image: none;
|
|
mask-image: none;
|
|
opacity: 1;
|
|
}
|
|
|
|
.layout-slot-container > * {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
</style>
|