feat(modal): route-targeted content modals on navigation
Add a global modal/popup system driven entirely from the CMS. A new `modal` config collection self-targets routes via a glob/regex urlPattern (optional time window), and its `content` array of block references renders through the existing BlockRenderer — so markdown, HTML and YouTube blocks work exactly like a page row. A `modals` list collection holds the active set behind an enable flag (analogous to `campaigns`). - modal-match.ts: glob (`*`/`**`) + `~regex` pathname matcher, time window and modal selection helpers (+ unit tests) - ContentModal.svelte: immediate/delay/scroll/exit_intent triggers, session vs. permanent dismiss (sessionStorage + localStorage, keyed by slug+version) - +layout.server.ts: load `modals-global` in the bootstrap batch, resolve block images server-side - +layout.svelte: mount on non-embed routes, match against the current path Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import "$lib/iconify-offline";
|
||||||
|
import BlockRenderer from "./BlockRenderer.svelte";
|
||||||
|
import type { Translations } from "$lib/translations";
|
||||||
|
import type { ResolvedBlock } from "$lib/block-types";
|
||||||
|
import {
|
||||||
|
selectModal,
|
||||||
|
dismissKey,
|
||||||
|
type ModalLike,
|
||||||
|
} from "$lib/modal-match";
|
||||||
|
|
||||||
|
interface ModalEntry extends ModalLike {
|
||||||
|
trigger?: "immediate" | "delay" | "scroll" | "exit_intent";
|
||||||
|
delaySeconds?: number;
|
||||||
|
scrollPercent?: number;
|
||||||
|
content?: unknown[];
|
||||||
|
dismissible?: boolean;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
modals = [],
|
||||||
|
pathname,
|
||||||
|
translations = {},
|
||||||
|
}: {
|
||||||
|
modals?: ModalEntry[];
|
||||||
|
pathname: string;
|
||||||
|
translations?: Translations | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let active = $state<ModalEntry | null>(null);
|
||||||
|
let open = $state(false);
|
||||||
|
let dontShowAgain = $state(false);
|
||||||
|
|
||||||
|
/** Nur diese Session ausgeblendet (X/Escape/Backdrop). */
|
||||||
|
function sessionKey(m: ModalEntry): string {
|
||||||
|
return `modal-session:${m.slug ?? m._slug ?? "unknown"}:${m.version ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDismissed(m: ModalEntry): boolean {
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(dismissKey(m))) return true;
|
||||||
|
if (sessionStorage.getItem(sessionKey(m))) return true;
|
||||||
|
} catch {
|
||||||
|
/* Storage gesperrt (Private Mode) → nicht als dismissed werten. */
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(permanent: boolean) {
|
||||||
|
const m = active;
|
||||||
|
open = false;
|
||||||
|
if (!m) return;
|
||||||
|
try {
|
||||||
|
if (permanent && m.dismissible !== false) {
|
||||||
|
localStorage.setItem(dismissKey(m), "1");
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem(sessionKey(m), "1");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Storage gesperrt → Modal kommt beim nächsten Load wieder. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape" && open) close(dontShowAgain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClass = $derived(
|
||||||
|
active?.size === "sm"
|
||||||
|
? "max-w-md"
|
||||||
|
: active?.size === "lg"
|
||||||
|
? "max-w-3xl"
|
||||||
|
: "max-w-xl",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kandidat je Route neu wählen und Trigger scharfschalten. Cleanup läuft vor
|
||||||
|
// jedem Re-Run (Navigation) und beim Destroy — entfernt Timer/Listener.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const path = pathname;
|
||||||
|
const list = modals ?? [];
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
dontShowAgain = false;
|
||||||
|
const m = selectModal<ModalEntry>(list, path, Date.now(), isDismissed);
|
||||||
|
active = m;
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
open = true;
|
||||||
|
};
|
||||||
|
let removeScroll: (() => void) | null = null;
|
||||||
|
let removeExit: (() => void) | null = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
switch (m.trigger ?? "delay") {
|
||||||
|
case "immediate":
|
||||||
|
show();
|
||||||
|
break;
|
||||||
|
case "delay":
|
||||||
|
timer = setTimeout(show, Math.max(0, m.delaySeconds ?? 3) * 1000);
|
||||||
|
break;
|
||||||
|
case "scroll": {
|
||||||
|
const pct = Math.min(100, Math.max(1, m.scrollPercent ?? 30));
|
||||||
|
const onScroll = () => {
|
||||||
|
const h =
|
||||||
|
document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const scrolled = h > 0 ? (window.scrollY / h) * 100 : 100;
|
||||||
|
if (scrolled >= pct) {
|
||||||
|
show();
|
||||||
|
removeScroll?.();
|
||||||
|
removeScroll = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
removeScroll = () =>
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
onScroll(); // Fall: Seite kürzer als Trigger → sofort
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "exit_intent": {
|
||||||
|
// Nur Desktop (feiner Zeiger) — Touch hat kein Exit-Intent.
|
||||||
|
if (window.matchMedia("(pointer: fine)").matches) {
|
||||||
|
const onOut = (e: MouseEvent) => {
|
||||||
|
if (e.clientY <= 0) show();
|
||||||
|
};
|
||||||
|
document.addEventListener("mouseout", onOut);
|
||||||
|
removeExit = () =>
|
||||||
|
document.removeEventListener("mouseout", onOut);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
removeScroll?.();
|
||||||
|
removeExit?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocks = $derived((active?.content ?? []) as ResolvedBlock[]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeydown} />
|
||||||
|
|
||||||
|
{#if open && active}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||||
|
role="presentation"
|
||||||
|
onclick={() => close(dontShowAgain)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative w-full {sizeClass} max-h-[90vh] overflow-y-auto rounded-sm bg-white p-6 shadow-2xl md:p-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => close(dontShowAgain)}
|
||||||
|
class="absolute top-3 right-3 rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:x" class="size-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="content-modal-body">
|
||||||
|
{#each blocks as block (block)}
|
||||||
|
<BlockRenderer {block} {translations} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if active.dismissible !== false}
|
||||||
|
<div class="mt-6 flex items-center justify-between gap-4 border-t border-gray-200 pt-4">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 accent-wald-700"
|
||||||
|
bind:checked={dontShowAgain}
|
||||||
|
/>
|
||||||
|
<span>Dauerhaft nicht mehr anzeigen</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm bg-wald-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wald-800"
|
||||||
|
onclick={() => close(dontShowAgain)}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { matchUrlPattern, inTimeWindow, selectModal } from './modal-match';
|
||||||
|
|
||||||
|
describe('matchUrlPattern — glob', () => {
|
||||||
|
it('exact path', () => {
|
||||||
|
expect(matchUrlPattern('/aktion', '/aktion')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/aktion', '/aktion/x')).toBe(false);
|
||||||
|
expect(matchUrlPattern('/aktion', '/aktionismus')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trailing slash is irrelevant', () => {
|
||||||
|
expect(matchUrlPattern('/aktion', '/aktion/')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/aktion/', '/aktion')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('root', () => {
|
||||||
|
expect(matchUrlPattern('/', '/')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/', '/x')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single segment *', () => {
|
||||||
|
expect(matchUrlPattern('/aktion/*', '/aktion/x')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/aktion/*', '/aktion/x/y')).toBe(false);
|
||||||
|
// '/aktion/*' matcht nicht /aktion selbst
|
||||||
|
expect(matchUrlPattern('/aktion/*', '/aktion')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursive ** includes the prefix itself', () => {
|
||||||
|
expect(matchUrlPattern('/aktion/**', '/aktion')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/aktion/**', '/aktion/')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/aktion/**', '/aktion/x')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/aktion/**', '/aktion/x/y')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/aktion/**', '/andere')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('post section', () => {
|
||||||
|
expect(matchUrlPattern('/post/**', '/post/mein-beitrag')).toBe(true);
|
||||||
|
expect(matchUrlPattern('/post/**', '/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('global patterns', () => {
|
||||||
|
expect(matchUrlPattern('**', '/irgendwas/tief')).toBe(true);
|
||||||
|
expect(matchUrlPattern('**', '/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('case-insensitive', () => {
|
||||||
|
expect(matchUrlPattern('/Aktion', '/aktion')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores query/hash on pathname', () => {
|
||||||
|
expect(matchUrlPattern('/aktion', '/aktion?x=1')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchUrlPattern — regex (~ prefix)', () => {
|
||||||
|
it('applies regex', () => {
|
||||||
|
expect(matchUrlPattern('~^/download-\\d+$', '/download-42')).toBe(true);
|
||||||
|
expect(matchUrlPattern('~^/download-\\d+$', '/download-abc')).toBe(false);
|
||||||
|
});
|
||||||
|
it('broken regex → no match, no throw', () => {
|
||||||
|
expect(matchUrlPattern('~(', '/x')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inTimeWindow', () => {
|
||||||
|
const now = Date.parse('2026-07-06T12:00:00Z');
|
||||||
|
it('open window', () => {
|
||||||
|
expect(inTimeWindow({}, now)).toBe(true);
|
||||||
|
});
|
||||||
|
it('before from', () => {
|
||||||
|
expect(inTimeWindow({ timeFrom: '2026-07-10T00:00:00Z' }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
it('after until', () => {
|
||||||
|
expect(inTimeWindow({ timeUntil: '2026-07-01T00:00:00Z' }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
it('within', () => {
|
||||||
|
expect(
|
||||||
|
inTimeWindow(
|
||||||
|
{ timeFrom: '2026-07-01T00:00:00Z', timeUntil: '2026-07-10T00:00:00Z' },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectModal', () => {
|
||||||
|
const now = Date.parse('2026-07-06T12:00:00Z');
|
||||||
|
const never = () => false;
|
||||||
|
it('picks first matching, in-window, not dismissed', () => {
|
||||||
|
const modals = [
|
||||||
|
{ slug: 'a', urlPattern: '/other' },
|
||||||
|
{ slug: 'b', urlPattern: '/aktion/**' },
|
||||||
|
{ slug: 'c', urlPattern: '**' },
|
||||||
|
];
|
||||||
|
expect(selectModal(modals, '/aktion/x', now, never)?.slug).toBe('b');
|
||||||
|
});
|
||||||
|
it('skips dismissed', () => {
|
||||||
|
const modals = [
|
||||||
|
{ slug: 'b', urlPattern: '**' },
|
||||||
|
{ slug: 'c', urlPattern: '**' },
|
||||||
|
];
|
||||||
|
const dismissed = (m: { slug?: string }) => m.slug === 'b';
|
||||||
|
expect(selectModal(modals, '/x', now, dismissed)?.slug).toBe('c');
|
||||||
|
});
|
||||||
|
it('returns null when nothing matches', () => {
|
||||||
|
expect(selectModal([{ slug: 'a', urlPattern: '/x' }], '/y', now, never)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -19,7 +19,8 @@ import type {
|
|||||||
TopBannerEntry,
|
TopBannerEntry,
|
||||||
} from '$lib/cms';
|
} from '$lib/cms';
|
||||||
import type { DeadlineBannerBlockData } from '$lib/block-types';
|
import type { DeadlineBannerBlockData } from '$lib/block-types';
|
||||||
import { ensureTransformedImage } from '$lib/rusty-image';
|
import { ensureTransformedImage, resolveContentImages } from '$lib/rusty-image';
|
||||||
|
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SOCIAL_IMAGE_URL,
|
DEFAULT_SOCIAL_IMAGE_URL,
|
||||||
ROW_RESOLVE,
|
ROW_RESOLVE,
|
||||||
@@ -42,6 +43,28 @@ interface SocialLink {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein aufgelöstes Modal (config-Collection `modal`). Content = Block-Liste. */
|
||||||
|
export interface ModalEntry {
|
||||||
|
_slug?: string;
|
||||||
|
slug?: string;
|
||||||
|
urlPattern?: string;
|
||||||
|
trigger?: 'immediate' | 'delay' | 'scroll' | 'exit_intent';
|
||||||
|
delaySeconds?: number;
|
||||||
|
scrollPercent?: number;
|
||||||
|
content?: unknown[];
|
||||||
|
dismissible?: boolean;
|
||||||
|
version?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
timeFrom?: string | null;
|
||||||
|
timeUntil?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der `modals`-Config-Eintrag: enable-Flag + Liste aufgelöster Modals. */
|
||||||
|
interface ModalsConfigEntry {
|
||||||
|
enable?: boolean;
|
||||||
|
modals?: ModalEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
|
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
|
||||||
function normalizeSlug(s: string | undefined): string {
|
function normalizeSlug(s: string | undefined): string {
|
||||||
return (s ?? '').replace(/^\//, '').trim();
|
return (s ?? '').replace(/^\//, '').trim();
|
||||||
@@ -77,6 +100,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
seoTitleTemplate: null,
|
seoTitleTemplate: null,
|
||||||
seoDescriptionTemplate: null,
|
seoDescriptionTemplate: null,
|
||||||
analyticsPm86SiteId: null,
|
analyticsPm86SiteId: null,
|
||||||
|
modals: [] as ModalEntry[],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,13 +115,14 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
let bootstrapConfig: PageConfigEntry | null = null;
|
let bootstrapConfig: PageConfigEntry | null = null;
|
||||||
let bootstrapTopBanner: TopBannerEntry | null = null;
|
let bootstrapTopBanner: TopBannerEntry | null = null;
|
||||||
let bootstrapDeadlineBanner: DeadlineBannerBlockData | null = null;
|
let bootstrapDeadlineBanner: DeadlineBannerBlockData | null = null;
|
||||||
|
let activeModals: ModalEntry[] = [];
|
||||||
try {
|
try {
|
||||||
// TTL-Cache über den ganzen Batch — lief vorher ungecacht bei jedem
|
// TTL-Cache über den ganzen Batch — lief vorher ungecacht bei jedem
|
||||||
// SSR-Request. CSV-Key-Head: Webhook-Purge jeder beteiligten Collection
|
// SSR-Request. CSV-Key-Head: Webhook-Purge jeder beteiligten Collection
|
||||||
// invalidiert den Eintrag (siehe invalidateCollection in cms.ts).
|
// invalidiert den Eintrag (siehe invalidateCollection in cms.ts).
|
||||||
const batch = await cached(
|
const batch = await cached(
|
||||||
'navigation',
|
'navigation',
|
||||||
'navigation,footer,page_config,top_banner,deadline_banner:layout-bootstrap:de',
|
'navigation,footer,page_config,top_banner,deadline_banner,modals:layout-bootstrap:de',
|
||||||
() => batchFetch([
|
() => batchFetch([
|
||||||
{
|
{
|
||||||
id: 'navHeader',
|
id: 'navHeader',
|
||||||
@@ -141,6 +166,15 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
locale: 'de',
|
locale: 'de',
|
||||||
resolve: 'all',
|
resolve: 'all',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Globale Modal-Liste (config-Collection `modals`, ein Eintrag). Enthält
|
||||||
|
// die aktiven Modals als aufgelöste Referenzen inkl. content-Blocks.
|
||||||
|
id: 'modals',
|
||||||
|
collection: 'modals',
|
||||||
|
slug: 'modals-global',
|
||||||
|
locale: 'de',
|
||||||
|
resolve: 'all',
|
||||||
|
},
|
||||||
]));
|
]));
|
||||||
bootstrapNavHeader = batchData<NavigationEntry>(batch.results.navHeader);
|
bootstrapNavHeader = batchData<NavigationEntry>(batch.results.navHeader);
|
||||||
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
|
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
|
||||||
@@ -148,11 +182,30 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
|
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
|
||||||
bootstrapTopBanner = batchData<TopBannerEntry>(batch.results.topBanner);
|
bootstrapTopBanner = batchData<TopBannerEntry>(batch.results.topBanner);
|
||||||
bootstrapDeadlineBanner = batchData<DeadlineBannerBlockData>(batch.results.deadlineBanner);
|
bootstrapDeadlineBanner = batchData<DeadlineBannerBlockData>(batch.results.deadlineBanner);
|
||||||
|
const modalsConfig = batchData<ModalsConfigEntry>(batch.results.modals);
|
||||||
|
if (modalsConfig?.enable !== false) {
|
||||||
|
activeModals = (modalsConfig?.modals ?? []).filter(
|
||||||
|
(m): m is ModalEntry => typeof m === 'object' && m !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
/* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */
|
/* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */
|
||||||
logWarn('layout.bootstrap', err);
|
logWarn('layout.bootstrap', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bilder/Markdown in den Modal-Content-Blocks server-seitig auflösen — gleiche
|
||||||
|
// Pipeline wie Page-Rows (resolveContentImages erwartet rowNContent-Shape,
|
||||||
|
// darum content als row1Content wrappen; Blocks werden in-place mutiert).
|
||||||
|
try {
|
||||||
|
for (const m of activeModals) {
|
||||||
|
if (!Array.isArray(m.content) || m.content.length === 0) continue;
|
||||||
|
await resolveContentImages({ row1Content: m.content });
|
||||||
|
sanitizeBlocks(m);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('layout.modals', err);
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback für config: Slug "default" probieren (legacy), wenn die kanonische
|
// Fallback für config: Slug "default" probieren (legacy), wenn die kanonische
|
||||||
// Variante fehlt. Selten genug für einen weiteren Single-Call.
|
// Variante fehlt. Selten genug für einen weiteren Single-Call.
|
||||||
if (!bootstrapConfig) {
|
if (!bootstrapConfig) {
|
||||||
@@ -416,5 +469,6 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
(pageConfig as { analyticsPm86SiteId?: string } | null)?.analyticsPm86SiteId ?? null,
|
(pageConfig as { analyticsPm86SiteId?: string } | null)?.analyticsPm86SiteId ?? null,
|
||||||
announcementBanner: bootstrapTopBanner,
|
announcementBanner: bootstrapTopBanner,
|
||||||
deadlineBanner: bootstrapDeadlineBanner,
|
deadlineBanner: bootstrapDeadlineBanner,
|
||||||
|
modals: activeModals,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import AnnouncementBanner from '$lib/components/AnnouncementBanner.svelte';
|
import AnnouncementBanner from '$lib/components/AnnouncementBanner.svelte';
|
||||||
import DeadlineBannerBlock from '$lib/components/blocks/DeadlineBannerBlock.svelte';
|
import DeadlineBannerBlock from '$lib/components/blocks/DeadlineBannerBlock.svelte';
|
||||||
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
|
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
|
||||||
|
import ContentModal from '$lib/components/ContentModal.svelte';
|
||||||
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
|
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import { ensureTransformedImage } from '$lib/rusty-image';
|
import { ensureTransformedImage } from '$lib/rusty-image';
|
||||||
@@ -406,5 +407,11 @@
|
|||||||
<Footer footer={data.footerData} translations={data.translations} />
|
<Footer footer={data.footerData} translations={data.translations} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContentModal
|
||||||
|
modals={data.modals}
|
||||||
|
pathname={$page.url.pathname}
|
||||||
|
translations={data.translations}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user