From 84c41f80b14e0e5f6281c7ed6b638f82c041c60e Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Mon, 6 Jul 2026 09:47:49 +0200 Subject: [PATCH] feat(modal): route-targeted content modals on navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/components/ContentModal.svelte | 198 +++++++++++++++++++++++++ src/lib/modal-match.test.ts | 108 ++++++++++++++ src/lib/modal-match.ts | Bin 0 -> 3175 bytes src/routes/+layout.server.ts | 58 +++++++- src/routes/+layout.svelte | 7 + 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/ContentModal.svelte create mode 100644 src/lib/modal-match.test.ts create mode 100644 src/lib/modal-match.ts diff --git a/src/lib/components/ContentModal.svelte b/src/lib/components/ContentModal.svelte new file mode 100644 index 0000000..53fbc6a --- /dev/null +++ b/src/lib/components/ContentModal.svelte @@ -0,0 +1,198 @@ + + + + +{#if open && active} + +{/if} diff --git a/src/lib/modal-match.test.ts b/src/lib/modal-match.test.ts new file mode 100644 index 0000000..421226f --- /dev/null +++ b/src/lib/modal-match.test.ts @@ -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); + }); +}); diff --git a/src/lib/modal-match.ts b/src/lib/modal-match.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cd0034e5fe51261bde2dbfee06373949fe0051e GIT binary patch literal 3175 zcmZ`*-EP}96z+AN;^r1nTS;cXwnU!)^L>X*TCFW=(ea0Oo%gA=qR^d#)Q;y;&uDu4t)PQ^ zk}5-?kzy_=ukt548^^eY+(CB;b2$VxG#FfGS(QbH6B(Ih>yGS{%qpMU@Si^K(%s9N8&S}i4P z-HTQ$RO$((N(n=gv?w*Qeo)UjVHXowh%+nbt<)1y(3f1-{wUOPJYTsdBh<*)M#Sts zXSE=~6Pt6c+2aQ>6Bk`NC=EiOOj-)Q2B^bKYAtjd`^J*QSHOqx;&q{2muLnHnkM*i znJLWYTF`H$C@!NmjZ-5!QX8R-wDOB+lPyxI(ArL4rDjfIhGrs=WNQmmqNC=n&WkKn z5{)llKm3sAmPSg*k)^vup_mv-ps)7nTZ^u&CWaLI6rs1UX+I1uL| zHcE~~_`FS>orvuTKzYNudDUGUr!R+ ziRisb?G=17XdFjdlo0x7guP?tJbox2t^-2Ju=%6s;biw;SM08aax8KHRXg(;0 zw&s&d_~$^eLKGKbp;FW@8MFqiWY#7hBY=Hpm-l>}clS=>XT49`*UK=vIT@S|2B$0< z3$m2?B1^+3AwOB)lO`x%B zc2*``;GUBUW}|lT!FyLO$k#bx!h=G8iTs5$QKU<7gUvBenU z?B>og(Grshgt}|OhtBOhOBW^1B9CI%Gk0wGk%yS9rJoB7Fc-`czx=HT-eBH3@2D9@ zMf(G(C1zZwKL<1Tw8D{)meZL~xBp-!Yk&H6_N4}BX2!A2kc$Ho&QZSVDkMZP1!cU- zJ9#Etet#HM&U$qRxiR+^*2ic=?u#f4fZ15HG84ZPdA3@l8`7mxY?t?rwUz4rW|eK< zrX#5^G*muLRo~{wNp#+d%PuX7yvntYc$>|2m;c9rl$>?_> z?G)!6kHnL@N;vQ8nw<9NCm|A92d(=Z)0bV9sUGs}W&h8)Q(Jl8#Z?Jr?oXEu!r7w4!0`~{OU-_PE`THq3p&63dydn)jf<9J z7OqmDbzXwpi`3vo@Qh@$d$XAcJ2w zV(X$kP>tx-Zaui$TR_iI#acHYrg=f3M@vTewDMT@(#`m+7q4eT#8)XRH5Yw_pSwp% mwxO^-M=-x1&~1X=A^kzYjl{z2o { seoTitleTemplate: null, seoDescriptionTemplate: null, analyticsPm86SiteId: null, + modals: [] as ModalEntry[], }; } @@ -91,13 +115,14 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { let bootstrapConfig: PageConfigEntry | null = null; let bootstrapTopBanner: TopBannerEntry | null = null; let bootstrapDeadlineBanner: DeadlineBannerBlockData | null = null; + let activeModals: ModalEntry[] = []; try { // TTL-Cache über den ganzen Batch — lief vorher ungecacht bei jedem // SSR-Request. CSV-Key-Head: Webhook-Purge jeder beteiligten Collection // invalidiert den Eintrag (siehe invalidateCollection in cms.ts). const batch = await cached( '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([ { id: 'navHeader', @@ -141,6 +166,15 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { locale: 'de', 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(batch.results.navHeader); bootstrapNavSocial = batchData(batch.results.navSocial); @@ -148,11 +182,30 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { bootstrapConfig = batchData(batch.results.config); bootstrapTopBanner = batchData(batch.results.topBanner); bootstrapDeadlineBanner = batchData(batch.results.deadlineBanner); + const modalsConfig = batchData(batch.results.modals); + if (modalsConfig?.enable !== false) { + activeModals = (modalsConfig?.modals ?? []).filter( + (m): m is ModalEntry => typeof m === 'object' && m !== null, + ); + } } catch (err) { /* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */ 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 // Variante fehlt. Selten genug für einen weiteren Single-Call. if (!bootstrapConfig) { @@ -416,5 +469,6 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { (pageConfig as { analyticsPm86SiteId?: string } | null)?.analyticsPm86SiteId ?? null, announcementBanner: bootstrapTopBanner, deadlineBanner: bootstrapDeadlineBanner, + modals: activeModals, }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 90d673b..c1a0b19 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import AnnouncementBanner from '$lib/components/AnnouncementBanner.svelte'; import DeadlineBannerBlock from '$lib/components/blocks/DeadlineBannerBlock.svelte'; import TranslationProvider from '$lib/components/TranslationProvider.svelte'; + import ContentModal from '$lib/components/ContentModal.svelte'; import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte'; import type { LayoutData } from './$types'; import { ensureTransformedImage } from '$lib/rusty-image'; @@ -406,5 +407,11 @@