Files
BetiX/resources/js/pages/policies/_PolicyLayout.vue
Dolo 0280278978
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Initialer Laravel Commit für BetiX
2026-04-04 18:01:50 +02:00

182 lines
7.1 KiB
Vue

<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
import UserLayout from '@/layouts/user/userlayout.vue';
const props = withDefaults(defineProps<{
title: string;
updated?: string;
intro?: string;
metaDescription?: string;
}>(), {
intro: '',
metaDescription: ''
});
const contentRef = ref<HTMLElement | null>(null);
const toc = ref<{ id: string; text: string; level: number }[]>([]);
const activeId = ref<string>('');
const canonical = ref<string>('');
let observer: IntersectionObserver | null = null;
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim().replace(/\s+/g, '-').replace(/-+/g, '-');
}
function buildToc() {
if (!contentRef.value) return;
toc.value = [];
const headings = Array.from(contentRef.value.querySelectorAll('h2, h3')) as HTMLElement[];
for (const h of headings) {
if (!h.id) {
h.id = slugify(h.innerText || h.textContent || 'section');
}
// Ensure scroll margin for sticky headers
h.style.scrollMarginTop = '90px';
toc.value.push({ id: h.id, text: h.innerText || h.textContent || '', level: h.tagName === 'H2' ? 2 : 3 });
}
}
function setupObserver() {
if (!contentRef.value) return;
cleanupObserver();
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).id;
activeId.value = id;
break;
}
}
}, { root: null, rootMargin: '0px 0px -70% 0px', threshold: [0, 1.0] });
contentRef.value.querySelectorAll('h2, h3').forEach(el => observer!.observe(el));
}
function cleanupObserver() {
if (observer) { observer.disconnect(); observer = null; }
}
function scrollToId(id: string) {
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - 80; // offset for sticky header
window.scrollTo({ top, behavior: 'smooth' });
}
onMounted(async () => {
await nextTick();
buildToc();
setupObserver();
// compute canonical on client only to avoid SSR/window issues
try {
if (typeof window !== 'undefined' && window?.location) {
canonical.value = window.location.origin + window.location.pathname;
}
} catch {}
// Rebuild if icons/fonts change layout slightly
setTimeout(buildToc, 50);
});
onBeforeUnmount(() => cleanupObserver());
</script>
<template>
<UserLayout>
<Head :title="props.title">
<template #default>
<meta v-if="props.metaDescription" name="description" :content="props.metaDescription" />
<link v-if="canonical" rel="canonical" :href="canonical" />
</template>
</Head>
<section class="policy-wrap">
<div class="policy-grid">
<!-- Desktop Sticky ToC -->
<nav class="toc" :aria-label="$t('policy.toc')">
<div class="toc-title">{{ $t('policy.toc') }}</div>
<ul>
<li v-for="item in toc" :key="item.id" :class="['lvl'+item.level, { active: activeId===item.id }]">
<a :href="`#${item.id}`" @click.prevent="scrollToId(item.id)">{{ item.text }}</a>
</li>
</ul>
</nav>
<div class="policy-card">
<header class="p-head">
<h1 class="p-title">{{ props.title }}</h1>
<div class="p-updated" v-if="props.updated">{{ $t('policy.lastUpdated') }}: {{ new Date(props.updated).toLocaleDateString() }}</div>
<p v-if="props.intro" class="p-intro">{{ props.intro }}</p>
<!-- Mobile ToC toggle -->
<details class="toc-mobile">
<summary>{{ $t('policy.toc') }}</summary>
<ul>
<li v-for="item in toc" :key="item.id" :class="['lvl'+item.level, { active: activeId===item.id }]">
<a :href="`#${item.id}`" @click.prevent="scrollToId(item.id)">{{ item.text }}</a>
</li>
</ul>
</details>
</header>
<div class="p-body" ref="contentRef">
<slot />
</div>
</div>
</div>
</section>
</UserLayout>
</template>
<style scoped>
.policy-wrap { padding: 30px; display: flex; justify-content: center; }
.policy-grid { width: 100%; max-width: 1100px; display: grid; grid-template-columns: 260px 1fr; gap: 20px; }
/* ToC Desktop */
.toc { position: sticky; top: 90px; align-self: start; background: #0a0a0a; border: 1px solid #151515; border-radius: 12px; padding: 14px; height: fit-content; display: none; }
.toc-title { font-size: 12px; font-weight: 900; color: #fff; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
.toc ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.toc li a { color: #aaa; text-decoration: none; font-size: 12px; font-weight: 700; display: block; padding: 6px 8px; border-radius: 8px; }
.toc li a:hover, .toc li.active a { color: #fff; background: rgba(0,242,255,0.06); }
.toc li.lvl3 a { padding-left: 18px; font-weight: 600; }
/* Show ToC on desktop */
@media (min-width: 1000px) { .toc { display: block; } }
/* Card */
.policy-card { width: 100%; background: #0a0a0a; border: 1px solid #151515; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.6); overflow:hidden; }
.p-head { padding: 22px 26px; border-bottom: 1px solid #151515; background: linear-gradient(90deg, rgba(0,242,255,0.05), transparent); }
.p-title { margin: 0; font-size: 24px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.p-updated { margin-top: 6px; font-size: 12px; color: #777; }
.p-intro { margin-top: 10px; font-size: 13px; color: #bbb; }
/* Mobile ToC */
.toc-mobile { display: block; margin-top: 12px; }
.toc-mobile > summary { cursor: pointer; list-style: none; background: #0a0a0a; border: 1px solid #1a1a1a; color: #fff; padding: 8px 10px; border-radius: 8px; font-size: 12px; font-weight: 900; }
.toc-mobile[open] > summary { background: #121212; }
.toc-mobile ul { list-style: none; padding: 8px; margin: 6px 0 0; display: grid; gap: 6px; border: 1px solid #1a1a1a; border-radius: 10px; }
.toc-mobile li a { color: #aaa; text-decoration: none; font-size: 12px; font-weight: 700; display: block; padding: 6px 8px; border-radius: 8px; }
.toc-mobile li a:hover, .toc-mobile li.active a { color: #fff; background: rgba(0,242,255,0.06); }
.toc-mobile li.lvl3 a { padding-left: 18px; font-weight: 600; }
/* Body */
.p-body { padding: 24px 26px; display: grid; gap: 18px; color: #bbb; line-height: 1.8; }
.p-body h2 { color: #fff; font-size: 18px; font-weight: 900; margin: 24px 0 6px; }
.p-body h3 { color: #ddd; font-size: 15px; font-weight: 800; margin: 14px 0 4px; }
.p-body p, .p-body li { font-size: 13px; }
.p-body ul { padding-left: 18px; list-style: disc; }
.callout { background:#0e0e0e; border:1px solid #1f1f1f; padding:12px 14px; border-radius:10px; color:#9bd; }
/* Print */
@media print {
:host, .policy-wrap, .policy-grid, .policy-card, .p-body { all: unset; display: block; }
.toc, .toc-mobile, .p-intro { display: none !important; }
body { color: #000; }
a[href]::after { content: " (" attr(href) ")"; font-size: 10px; }
}
</style>