279 lines
9.6 KiB
Plaintext
279 lines
9.6 KiB
Plaintext
---
|
|
import '@/styles/global.css';
|
|
import SEO from '@/components/seo/SEO.astro';
|
|
import JsonLd from '@/components/seo/JsonLd.astro';
|
|
import Analytics from '@/components/layout/Analytics.astro';
|
|
import ConsentBanner from '@/components/ui/overlay/ConsentBanner';
|
|
import { ClientRouter } from 'astro:transitions';
|
|
import { createWebsiteSchema, createOrganizationSchema, createPersonSchema, createProfessionalServiceSchema } from '@/lib/schema';
|
|
import type { WebSite, Organization, Person, WithContext } from 'schema-dts';
|
|
import siteConfig from '@/config/site.config';
|
|
|
|
interface Props {
|
|
title?: string;
|
|
description?: string;
|
|
image?: string;
|
|
imageAlt?: string;
|
|
article?: {
|
|
publishedTime?: Date;
|
|
modifiedTime?: Date;
|
|
authors?: string[];
|
|
tags?: string[];
|
|
};
|
|
noindex?: boolean;
|
|
nofollow?: boolean;
|
|
includeOrgSchema?: boolean;
|
|
includePersonSchema?: boolean;
|
|
includeProfessionalServiceSchema?: boolean;
|
|
}
|
|
|
|
const {
|
|
title,
|
|
description,
|
|
image,
|
|
imageAlt,
|
|
article,
|
|
noindex = false,
|
|
nofollow = false,
|
|
includeOrgSchema = false,
|
|
includePersonSchema = false,
|
|
includeProfessionalServiceSchema = false,
|
|
} = Astro.props;
|
|
|
|
// Build JSON-LD schemas
|
|
const schemas: Array<WithContext<WebSite> | WithContext<Organization> | WithContext<Person>> = [createWebsiteSchema()];
|
|
if (includeOrgSchema) {
|
|
schemas.push(createOrganizationSchema());
|
|
}
|
|
if (includePersonSchema) {
|
|
schemas.push(createPersonSchema());
|
|
}
|
|
if (includeProfessionalServiceSchema) {
|
|
schemas.push(createProfessionalServiceSchema());
|
|
}
|
|
---
|
|
|
|
<!doctype html>
|
|
<html lang="en" class="scroll-smooth dark" data-theme="blue">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta name="generator" content={Astro.generator} />
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/svg+xml" href={siteConfig.branding.favicon.svg} />
|
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
<meta name="theme-color" content={siteConfig.branding.colors.themeColor} />
|
|
|
|
<!-- SEO -->
|
|
<SEO
|
|
title={title}
|
|
description={description}
|
|
image={image}
|
|
imageAlt={imageAlt}
|
|
article={article}
|
|
noindex={noindex}
|
|
nofollow={nofollow}
|
|
/>
|
|
|
|
<!-- RSS Feed -->
|
|
<link rel="alternate" type="application/rss+xml" title={`${siteConfig.name} RSS Feed`} href="/rss.xml" />
|
|
|
|
<!-- JSON-LD Structured Data -->
|
|
<JsonLd schema={schemas} />
|
|
|
|
<!-- Analytics (loads if PUBLIC_GA_MEASUREMENT_ID or PUBLIC_GTM_ID is set) -->
|
|
<Analytics />
|
|
|
|
<!-- View Transitions (client-side routing with animated page transitions) -->
|
|
<ClientRouter />
|
|
|
|
<!-- Random theme per session — runs synchronously to avoid flash -->
|
|
<script is:inline>
|
|
(function () {
|
|
var THEMES = ['orange','amber','lime','emerald','teal','cyan','sky','blue','indigo','violet','purple','magenta'];
|
|
var KEY = 'color-theme';
|
|
var theme;
|
|
try { theme = sessionStorage.getItem(KEY); } catch (e) {}
|
|
if (!theme) {
|
|
theme = THEMES[Math.floor(Math.random() * THEMES.length)];
|
|
try { sessionStorage.setItem(KEY, theme); } catch (e) {}
|
|
}
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
})();
|
|
</script>
|
|
|
|
<!-- Dynamic favicon: syncs letter (first letter of site name) and color (brand-500) with active theme -->
|
|
<script define:vars={{ _faviconLetter: siteConfig.name.charAt(0).toUpperCase() }}>
|
|
(function () {
|
|
const LETTER = _faviconLetter;
|
|
|
|
function updateFavicon() {
|
|
const color = getComputedStyle(document.documentElement).getPropertyValue('--brand-500').trim();
|
|
if (!color) return;
|
|
const svg =
|
|
'<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">' +
|
|
'<rect width="48" height="48" rx="8" fill="' + color + '"/>' +
|
|
'<text x="24" y="24" text-anchor="middle" dominant-baseline="central" ' +
|
|
'font-family="Outfit, system-ui, sans-serif" font-weight="700" font-size="34" fill="white">' +
|
|
LETTER +
|
|
'</text></svg>';
|
|
const link = document.querySelector('link[rel="icon"]');
|
|
if (link) link.setAttribute('href', 'data:image/svg+xml,' + encodeURIComponent(svg));
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', updateFavicon);
|
|
} else {
|
|
updateFavicon();
|
|
}
|
|
|
|
if (!window.__faviconListenerInit) {
|
|
window.__faviconListenerInit = true;
|
|
|
|
// Re-run whenever data-theme or dark/light class changes
|
|
new MutationObserver(function (mutations) {
|
|
for (let i = 0; i < mutations.length; i++) {
|
|
if (mutations[i].attributeName === 'data-theme' || mutations[i].attributeName === 'class') {
|
|
updateFavicon();
|
|
break;
|
|
}
|
|
}
|
|
}).observe(document.documentElement, { attributes: true });
|
|
|
|
// Re-run after Astro view transitions swap the head
|
|
document.addEventListener('astro:after-swap', updateFavicon);
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<!-- Theme script (runs before body paint; <html> starts with class="dark" as default) -->
|
|
<script is:inline>
|
|
(function () {
|
|
const COLOR_THEMES = ['orange', 'amber', 'lime', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'magenta'];
|
|
|
|
function applyTheme(el) {
|
|
try {
|
|
// Dark / light mode
|
|
if (sessionStorage.getItem('theme') === 'light') {
|
|
el.classList.remove('dark');
|
|
} else {
|
|
el.classList.add('dark');
|
|
}
|
|
} catch (_) { /* ignored */ }
|
|
|
|
try {
|
|
// Color theme — persisted in sessionStorage so it resets to blue on every new visit
|
|
const saved = sessionStorage.getItem('color-theme');
|
|
if (saved && COLOR_THEMES.indexOf(saved) !== -1) {
|
|
el.setAttribute('data-theme', saved);
|
|
}
|
|
} catch (_) { /* ignored */ }
|
|
}
|
|
|
|
applyTheme(document.documentElement);
|
|
|
|
if (!window.__themeListenersInit) {
|
|
window.__themeListenersInit = true;
|
|
|
|
document.addEventListener('astro:before-swap', function (e) {
|
|
applyTheme(e.newDocument.documentElement);
|
|
});
|
|
|
|
document.addEventListener('astro:after-swap', function () {
|
|
applyTheme(document.documentElement);
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
</head>
|
|
|
|
<body class="min-h-screen bg-background text-foreground antialiased">
|
|
<!-- Skip to content link -->
|
|
<a
|
|
href="#main-content"
|
|
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground"
|
|
>
|
|
Skip to content
|
|
</a>
|
|
|
|
<slot name="header" />
|
|
|
|
<main id="main-content" class="flex-1">
|
|
<slot />
|
|
</main>
|
|
|
|
<slot name="footer" />
|
|
|
|
<ConsentBanner />
|
|
|
|
<!-- Back to top button -->
|
|
<button
|
|
id="back-to-top"
|
|
aria-label="Back to top"
|
|
class="fixed bottom-6 right-6 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-background border border-border-strong text-foreground-muted shadow-md opacity-0 translate-y-2 pointer-events-none transition-[opacity,transform] duration-200"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m18 15-6-6-6 6"/></svg>
|
|
</button>
|
|
|
|
<script is:inline>
|
|
(function () {
|
|
const THRESHOLD = 400;
|
|
|
|
function initBackToTop() {
|
|
const btn = document.getElementById('back-to-top');
|
|
if (!btn || btn.dataset.bttInit) return;
|
|
btn.dataset.bttInit = 'true';
|
|
|
|
function update() {
|
|
if (window.scrollY > THRESHOLD) {
|
|
btn.classList.remove('opacity-0', 'translate-y-2', 'pointer-events-none');
|
|
btn.classList.add('opacity-100', 'translate-y-0');
|
|
} else {
|
|
btn.classList.add('opacity-0', 'translate-y-2', 'pointer-events-none');
|
|
btn.classList.remove('opacity-100', 'translate-y-0');
|
|
}
|
|
}
|
|
|
|
window.addEventListener('scroll', update, { passive: true });
|
|
btn.addEventListener('click', function () {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
});
|
|
update();
|
|
}
|
|
|
|
initBackToTop();
|
|
document.addEventListener('astro:page-load', initBackToTop);
|
|
document.addEventListener('astro:after-swap', initBackToTop);
|
|
})();
|
|
</script>
|
|
|
|
<!-- Scroll reveal: adds .is-visible to [data-reveal] elements as they enter the viewport -->
|
|
<script is:inline>
|
|
(function () {
|
|
function initReveal() {
|
|
const els = document.querySelectorAll('[data-reveal]:not(.is-visible)');
|
|
if (!els.length) return;
|
|
const observer = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('is-visible');
|
|
observer.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });
|
|
els.forEach(function (el) { observer.observe(el); });
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initReveal);
|
|
} else {
|
|
initReveal();
|
|
}
|
|
|
|
document.addEventListener('astro:after-swap', initReveal);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|