182 lines
7.1 KiB
Vue
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>
|