feat: enhance accessibility with skip to main content button and improve navigation handling

🔧 fix: update navigation href resolution to include localized paths

🆕 feat: add new FeatureIcon component for feature boxes

🎨 style: improve styling for prose elements in richtext blocks

🛠️ refactor: streamline medialib image loading and caching logic

📦 chore: update mock data handling to support new medialib entries

🔄 chore: synchronize i18n initialization and locale management

📝 docs: update video tour descriptions to reflect recent changes
This commit is contained in:
2026-05-12 13:55:32 +00:00
parent 8fb26fdeba
commit e84b87ed16
41 changed files with 1523 additions and 338 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ SetEnv MATOMO no
RewriteEngine On
RewriteBase /
RewriteRule ^/?api/(.*)$ http://tibi-server:8080/api/v1/_/__NAMESPACE__/$1 [P,QSA,L]
RewriteRule ^/?api/(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/$1 [P,QSA,L]
# Set the Host header for requests to sentry
RequestHeader set Host sentry.basehosts.de env=proxy-sentry
@@ -36,7 +36,7 @@ SetEnv MATOMO no
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^/?(.*)$ http://tibi-server:8080/api/v1/_/__NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr]
RewriteRule ^/?(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr]
# RewriteRule (.*) /spa.html [QSA,L]
</ifModule>
View File
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
<title id="title">Tibi Starter admin preview</title>
<desc id="desc">Abstract admin preview illustration for the Tibi starter project.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f2d45" />
<stop offset="50%" stop-color="#1f5f7a" />
<stop offset="100%" stop-color="#d9e7f1" />
</linearGradient>
<linearGradient id="card" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95" />
<stop offset="100%" stop-color="#dfeaf2" stop-opacity="0.92" />
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg)" />
<circle cx="1320" cy="140" r="160" fill="#ffffff" fill-opacity="0.14" />
<circle cx="260" cy="760" r="220" fill="#ffffff" fill-opacity="0.09" />
<rect x="180" y="150" width="1240" height="600" rx="36" fill="url(#card)" />
<rect x="240" y="210" width="280" height="480" rx="24" fill="#10324a" fill-opacity="0.92" />
<rect x="570" y="230" width="780" height="74" rx="18" fill="#1f5f7a" fill-opacity="0.18" />
<rect x="570" y="340" width="360" height="250" rx="24" fill="#1f5f7a" fill-opacity="0.16" />
<rect x="970" y="340" width="380" height="110" rx="24" fill="#1f5f7a" fill-opacity="0.13" />
<rect x="970" y="480" width="380" height="110" rx="24" fill="#1f5f7a" fill-opacity="0.13" />
<path d="M300 300h160M300 360h160M300 420h160M300 480h110" stroke="#e7f0f6" stroke-width="22" stroke-linecap="round" />
<path d="M635 470l92-92 78 78 142-142 180 180" fill="none" stroke="#0f2d45" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="748" cy="330" r="34" fill="#0f2d45" />
<text x="570" y="275" fill="#10324a" font-family="Georgia, serif" font-size="42" font-weight="700">Tibi Admin Nova Starter</text>
<text x="570" y="655" fill="#10324a" font-family="Arial, sans-serif" font-size="34">Lean starter base with content, media, i18n, and SSR.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" role="img" aria-labelledby="title desc">
<title id="title">Placeholder image</title>
<desc id="desc">Neutral placeholder graphic for missing media assets.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e5eef6" />
<stop offset="100%" stop-color="#cfdcea" />
</linearGradient>
</defs>
<rect width="1200" height="900" fill="url(#bg)" />
<g fill="none" stroke="#7c97b2" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
<rect x="120" y="120" width="960" height="660" rx="36" />
<path d="M220 650l190-190 140 140 170-210 260 260" />
<circle cx="410" cy="310" r="70" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 752 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#10324a" />
<path d="M18 20h28v8H36v20h-8V28H18z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 183 B

+8
View File
@@ -0,0 +1,8 @@
{
"name": "Tibi Svelte Starter",
"short_name": "Tibi Starter",
"icons": [],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+129 -33
View File
@@ -1,7 +1,8 @@
[
{
"id": "home-de",
"_id": "home-de",
"_id": {
"$oid": "6821c0a10000000000000001"
},
"active": true,
"type": "page",
"lang": "de",
@@ -21,19 +22,50 @@
"buttonTarget": ""
},
"heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
"image": "6821c0a10000000000000201"
}
},
{
"type": "features",
"headline": "Was dieses Template kann",
"tagline": "Features",
"tagline": "Highlights",
"anchorId": "features",
"padding": {
"top": "lg",
"bottom": "lg"
},
"text": "<div class='grid gap-8 sm:grid-cols-2 lg:grid-cols-3'><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M13 2L3 14h9l-1 8 10-12h-9l1-8z\"/></svg></div><h3>Svelte 5 Runes</h3><p>Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.01 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.49-9-10-9z\"/><circle cx=\"7.5\" cy=\"11.5\" r=\"1.5\"/><circle cx=\"10.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"14.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"17.5\" cy=\"11.5\" r=\"1.5\"/></svg></div><h3>Tailwind CSS 4</h3><p>Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 22v-5\"/><path d=\"M9 8V2\"/><path d=\"M15 8V2\"/><path d=\"M18 8v5a6 6 0 0 1-12 0V8h12z\"/></svg></div><h3>Tibi CMS API</h3><p>Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M2 12h20\"/><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"/></svg></div><h3>i18n Built-in</h3><p>Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M8 21h8\"/><path d=\"M12 17v4\"/></svg></div><h3>SSR via goja</h3><p>Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 2h6\"/><path d=\"M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.578A1 1 0 0 0 5.598 22h12.804a1 1 0 0 0 .878-1.422l-5.069-10.155A2 2 0 0 1 14 9.527V2\"/></svg></div><h3>Playwright Tests</h3><p>E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go.</p></div></div>"
"featureBoxes": [
{
"icon": "lightning",
"title": "Svelte 5 Runes",
"text": "Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance."
},
{
"icon": "palette",
"title": "Tailwind CSS 4",
"text": "Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds."
},
{
"icon": "database",
"title": "Tibi CMS API",
"text": "Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung."
},
{
"icon": "globe",
"title": "Mehrsprachig",
"text": "Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel."
},
{
"icon": "monitor",
"title": "SSR via goja",
"text": "Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung."
},
{
"icon": "flask",
"title": "Playwright Tests",
"text": "E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go."
}
]
},
{
"type": "richtext",
@@ -44,9 +76,9 @@
"top": "lg",
"bottom": "sm"
},
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
"image": "6821c0a10000000000000202",
"imagePosition": "right",
"text": "<p>Starte die Entwicklungsumgebung mit <code>make docker-up && make docker-start</code>. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.</p><p>Für Offline-Entwicklung aktiviere den Mock-Modus mit <code>MOCK=1</code> in der <code>.env</code>. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.</p><p>Jeder Block-Typ (Hero, Richtext, Accordion, Features) ist eine eigene Svelte-Komponente — <strong>erweiterbar und austauschbar</strong>.</p>"
"text": "<p>Starte die Entwicklungsumgebung mit <code>make docker-up && make docker-start</code>. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.</p><p>Für Offline-Entwicklung aktiviere den Mock-Modus mit <code>MOCK=1</code> in der <code>.env</code>. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.</p><p>Jeder Block-Typ (Hero, Richtext, Accordion, Kontaktformular) ist eine eigene Svelte-Komponente — <strong>erweiterbar und austauschbar</strong>.</p>"
},
{
"type": "accordion",
@@ -81,12 +113,19 @@
"meta": {
"title": "Tibi Svelte Starter — Modernes CMS-Template",
"description": "Svelte 5, Tailwind CSS 4, SSR, i18n und Playwright-Tests — das perfekte Starterkit.",
"keywords": "svelte, tibi, cms, starter, template"
"keywords": [
"svelte",
"tibi",
"cms",
"starter",
"template"
]
}
},
{
"id": "home-en",
"_id": "home-en",
"_id": {
"$oid": "6821c0a10000000000000002"
},
"active": true,
"type": "page",
"lang": "en",
@@ -106,7 +145,7 @@
"buttonTarget": ""
},
"heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
"image": "6821c0a10000000000000201"
}
},
{
@@ -118,7 +157,38 @@
"top": "lg",
"bottom": "lg"
},
"text": "<div class='grid gap-8 sm:grid-cols-2 lg:grid-cols-3'><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M13 2L3 14h9l-1 8 10-12h-9l1-8z\"/></svg></div><h3>Svelte 5 Runes</h3><p>Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.01 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.49-9-10-9z\"/><circle cx=\"7.5\" cy=\"11.5\" r=\"1.5\"/><circle cx=\"10.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"14.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"17.5\" cy=\"11.5\" r=\"1.5\"/></svg></div><h3>Tailwind CSS 4</h3><p>Utility-first styling with custom theme, dark-mode-ready and blazing fast builds.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 22v-5\"/><path d=\"M9 8V2\"/><path d=\"M15 8V2\"/><path d=\"M18 8v5a6 6 0 0 1-12 0V8h12z\"/></svg></div><h3>Tibi CMS API</h3><p>Collections, hooks, media library — all via REST API. With mock mode for offline development.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M2 12h20\"/><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"/></svg></div><h3>Built-in i18n</h3><p>Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M8 21h8\"/><path d=\"M12 17v4\"/></svg></div><h3>SSR via goja</h3><p>Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 2h6\"/><path d=\"M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.578A1 1 0 0 0 5.598 22h12.804a1 1 0 0 0 .878-1.422l-5.069-10.155A2 2 0 0 1 14 9.527V2\"/></svg></div><h3>Playwright Tests</h3><p>E2E, API, visual regression and video tours — all preconfigured and ready to go.</p></div></div>"
"featureBoxes": [
{
"icon": "lightning",
"title": "Svelte 5 Runes",
"text": "Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance."
},
{
"icon": "palette",
"title": "Tailwind CSS 4",
"text": "Utility-first styling with custom theme, dark-mode-ready and blazing fast builds."
},
{
"icon": "database",
"title": "Tibi CMS API",
"text": "Collections, hooks, media library — all via REST API. With mock mode for offline development."
},
{
"icon": "globe",
"title": "Built-in i18n",
"text": "Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible."
},
{
"icon": "monitor",
"title": "SSR via goja",
"text": "Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation."
},
{
"icon": "flask",
"title": "Playwright Tests",
"text": "E2E, API, visual regression and video tours — all preconfigured and ready to go."
}
]
},
{
"type": "richtext",
@@ -129,9 +199,9 @@
"top": "lg",
"bottom": "sm"
},
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
"image": "6821c0a10000000000000202",
"imagePosition": "right",
"text": "<p>Start the dev environment with <code>make docker-up && make docker-start</code>. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.</p><p>For offline development, enable mock mode with <code>MOCK=1</code> in <code>.env</code>. Content is loaded via the Tibi API and rendered with the BlockRenderer.</p><p>Each block type (Hero, Richtext, Accordion, Features) is its own Svelte component — <strong>extensible and swappable</strong>.</p>"
"text": "<p>Start the dev environment with <code>make docker-up && make docker-start</code>. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.</p><p>For offline development, enable mock mode with <code>MOCK=1</code> in <code>.env</code>. Content is loaded via the Tibi API and rendered with the BlockRenderer.</p><p>Each block type (Hero, Richtext, Accordion, Contact Form) is its own Svelte component — <strong>extensible and swappable</strong>.</p>"
},
{
"type": "accordion",
@@ -166,12 +236,19 @@
"meta": {
"title": "Tibi Svelte Starter — Modern CMS Template",
"description": "Svelte 5, Tailwind CSS 4, SSR, i18n and Playwright tests — the perfect starter kit.",
"keywords": "svelte, tibi, cms, starter, template"
"keywords": [
"svelte",
"tibi",
"cms",
"starter",
"template"
]
}
},
{
"id": "about-de",
"_id": "about-de",
"_id": {
"$oid": "6821c0a10000000000000003"
},
"active": true,
"type": "page",
"lang": "de",
@@ -186,7 +263,7 @@
"subline": "Gebaut für Teams, die schnell professionelle Webprojekte umsetzen wollen.",
"containerWidth": "full",
"heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80"
"image": "6821c0a10000000000000203"
}
},
{
@@ -205,7 +282,7 @@
"top": "md",
"bottom": "lg"
},
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
"image": "6821c0a10000000000000204",
"imagePosition": "left",
"text": "<p>Jede Komponente wurde sorgfältig ausgewählt:</p><ul><li><strong>Svelte 5</strong> — Reaktives Framework mit Runes-API</li><li><strong>Tailwind CSS 4</strong> — Utility-first CSS mit @theme</li><li><strong>esbuild</strong> — Extrem schneller Bundler</li><li><strong>Tibi CMS</strong> — Headless CMS mit Go-Backend</li><li><strong>goja SSR</strong> — Server-Side Rendering in Go</li><li><strong>Playwright</strong> — Modernes Testing-Framework</li></ul>"
}
@@ -213,12 +290,18 @@
"meta": {
"title": "Über das Template — Tibi Svelte Starter",
"description": "Architektur und Tech-Stack des Tibi Svelte Starter Templates.",
"keywords": "svelte, über uns, template, architektur"
"keywords": [
"svelte",
"über uns",
"template",
"architektur"
]
}
},
{
"id": "about-en",
"_id": "about-en",
"_id": {
"$oid": "6821c0a10000000000000004"
},
"active": true,
"type": "page",
"lang": "en",
@@ -233,7 +316,7 @@
"subline": "Built for teams who want to ship professional web projects fast.",
"containerWidth": "full",
"heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80"
"image": "6821c0a10000000000000203"
}
},
{
@@ -252,7 +335,7 @@
"top": "md",
"bottom": "lg"
},
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
"image": "6821c0a10000000000000204",
"imagePosition": "left",
"text": "<p>Every component was carefully chosen:</p><ul><li><strong>Svelte 5</strong> — Reactive framework with Runes API</li><li><strong>Tailwind CSS 4</strong> — Utility-first CSS with @theme</li><li><strong>esbuild</strong> — Extremely fast bundler</li><li><strong>Tibi CMS</strong> — Headless CMS with Go backend</li><li><strong>goja SSR</strong> — Server-side rendering in Go</li><li><strong>Playwright</strong> — Modern testing framework</li></ul>"
}
@@ -260,12 +343,18 @@
"meta": {
"title": "About the Template — Tibi Svelte Starter",
"description": "Architecture and tech stack of the Tibi Svelte Starter Template.",
"keywords": "svelte, about, template, architecture"
"keywords": [
"svelte",
"about",
"template",
"architecture"
]
}
},
{
"id": "contact-de",
"_id": "contact-de",
"_id": {
"$oid": "6821c0a10000000000000005"
},
"active": true,
"type": "page",
"lang": "de",
@@ -280,7 +369,7 @@
"subline": "Fragen, Feedback oder Projektanfragen? Schreib uns!",
"containerWidth": "full",
"heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
"image": "6821c0a10000000000000205"
}
},
{
@@ -295,12 +384,16 @@
"meta": {
"title": "Kontakt — Tibi Svelte Starter",
"description": "Nimm Kontakt mit uns auf.",
"keywords": "kontakt, anfrage"
"keywords": [
"kontakt",
"anfrage"
]
}
},
{
"id": "contact-en",
"_id": "contact-en",
"_id": {
"$oid": "6821c0a10000000000000006"
},
"active": true,
"type": "page",
"lang": "en",
@@ -315,7 +408,7 @@
"subline": "Questions, feedback or project inquiries? Get in touch!",
"containerWidth": "full",
"heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
"image": "6821c0a10000000000000205"
}
},
{
@@ -330,7 +423,10 @@
"meta": {
"title": "Contact — Tibi Svelte Starter",
"description": "Get in touch with us.",
"keywords": "contact, inquiry"
"keywords": [
"contact",
"inquiry"
]
}
}
]
+97
View File
@@ -0,0 +1,97 @@
[
{
"_id": {
"$oid": "6821c0a10000000000000201"
},
"file": {
"src": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Homepage Hero",
"alt": {
"de": "Abstrakte Ansicht einer vernetzten digitalen Weltkarte",
"en": "Abstract view of a connected digital world map"
},
"description": "Hero image for the homepage demo.",
"tags": [
"hero",
"homepage"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000202"
},
"file": {
"src": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Workflow",
"alt": {
"de": "Laptop mit geoeffnetem Code-Editor auf einem Schreibtisch",
"en": "Laptop with an open code editor on a desk"
},
"description": "Workflow image for the richtext demo block.",
"tags": [
"workflow",
"development"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000203"
},
"file": {
"src": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "About Team",
"alt": {
"de": "Team sitzt gemeinsam an einem grossen Tisch und arbeitet zusammen",
"en": "Team working together around a large table"
},
"description": "Hero image for the about page demo.",
"tags": [
"about",
"team"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000204"
},
"file": {
"src": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Technology Stack",
"alt": {
"de": "Nahaufnahme eines Bildschirms mit Code und farbiger Syntaxhervorhebung",
"en": "Close-up of a screen with code and colorful syntax highlighting"
},
"description": "Technology stack image for the about page demo.",
"tags": [
"about",
"code"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000205"
},
"file": {
"src": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Contact Hero",
"alt": {
"de": "Arbeitsplatz mit Laptop, Telefon und Notizbuch fuer Kontaktanfragen",
"en": "Workspace with laptop, phone, and notebook for contact inquiries"
},
"description": "Hero image for the contact page demo.",
"tags": [
"contact",
"hero"
]
}
]
+71 -23
View File
@@ -1,48 +1,96 @@
[
{
"id": "header-de",
"_id": "header-de",
"_id": {
"$oid": "6821c0a10000000000000101"
},
"language": "de",
"type": "header",
"elements": [
{ "name": "Startseite", "page": "/" },
{ "name": "Über uns", "page": "/ueber-uns" },
{ "name": "Kontakt", "page": "/kontakt" }
{
"name": "Startseite",
"page": "6821c0a10000000000000001"
},
{
"name": "Über uns",
"page": "6821c0a10000000000000003"
},
{
"name": "Kontakt",
"page": "6821c0a10000000000000005"
}
]
},
{
"id": "header-en",
"_id": "header-en",
"_id": {
"$oid": "6821c0a10000000000000102"
},
"language": "en",
"type": "header",
"elements": [
{ "name": "Home", "page": "/" },
{ "name": "About", "page": "/about" },
{ "name": "Contact", "page": "/contact" }
{
"name": "Home",
"page": "6821c0a10000000000000002"
},
{
"name": "About",
"page": "6821c0a10000000000000004"
},
{
"name": "Contact",
"page": "6821c0a10000000000000006"
}
]
},
{
"id": "footer-de",
"_id": "footer-de",
"_id": {
"$oid": "6821c0a10000000000000103"
},
"language": "de",
"type": "footer",
"elements": [
{ "name": "Startseite", "page": "/" },
{ "name": "Über uns", "page": "/ueber-uns" },
{ "name": "Kontakt", "page": "/kontakt" },
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
{
"name": "Startseite",
"page": "6821c0a10000000000000001"
},
{
"name": "Über uns",
"page": "6821c0a10000000000000003"
},
{
"name": "Kontakt",
"page": "6821c0a10000000000000005"
},
{
"name": "GitHub",
"external": true,
"externalUrl": "https://github.com"
}
]
},
{
"id": "footer-en",
"_id": "footer-en",
"_id": {
"$oid": "6821c0a10000000000000104"
},
"language": "en",
"type": "footer",
"elements": [
{ "name": "Home", "page": "/" },
{ "name": "About", "page": "/about" },
{ "name": "Contact", "page": "/contact" },
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
{
"name": "Home",
"page": "6821c0a10000000000000002"
},
{
"name": "About",
"page": "6821c0a10000000000000004"
},
{
"name": "Contact",
"page": "6821c0a10000000000000006"
},
{
"name": "GitHub",
"external": true,
"externalUrl": "https://github.com"
}
]
}
]
]
+102 -11
View File
@@ -22,6 +22,9 @@
stripLanguageFromPath,
} from "./lib/i18n"
const CONTENT_MEDIA_LOOKUP = ["blocks.heroImage.image:medialib", "blocks.image:medialib"].join(",")
const NAVIGATION_CONTENT_LOOKUP = "elements.page:content"
let { url = "" }: { url?: string } = $props()
initScrollRestoration()
@@ -50,6 +53,48 @@
}
})
function focusMainContent(updateHash = false) {
if (typeof window === "undefined") {
return
}
const mainContent = document.getElementById("main-content")
if (!(mainContent instanceof HTMLElement)) {
return
}
if (updateHash) {
window.history.pushState(window.history.state, "", "#main-content")
}
window.requestAnimationFrame(() => {
mainContent.scrollIntoView({ block: "start" })
mainContent.focus()
})
}
function handleSkipToMainContent() {
focusMainContent(true)
}
$effect(() => {
if (typeof window === "undefined") {
return
}
const handleHashChange = () => {
if (window.location.hash === "#main-content") {
focusMainContent()
}
}
window.addEventListener("hashchange", handleHashChange)
return () => {
window.removeEventListener("hashchange", handleHashChange)
}
})
// metrics
let oldPath = $state("")
$effect(() => {
@@ -74,6 +119,16 @@
let contentEntry = $state<ContentEntry | null>(null)
let headerNav = $state<NavigationEntry | null>(null)
let footerNav = $state<NavigationEntry | null>(null)
function resolveNavigationHref(item: NavigationElement): string {
const resolvedPagePath = item._lookup?.page?.path || (item.page?.startsWith("/") ? item.page : "/")
const localized = localizedPath(resolvedPagePath || "/")
if (!item.hash) return localized
const normalizedHash = item.hash.startsWith("#") ? item.hash : `#${item.hash}`
return `${localized}${normalizedHash}`
}
let loading = $state(true)
let notFound = $state(false)
@@ -103,18 +158,42 @@
try {
// Load navigation
const [headerEntries, footerEntries] = await Promise.all([
getCachedEntries<"navigation">("navigation", { type: "header", language: lang }),
getCachedEntries<"navigation">("navigation", { type: "footer", language: lang }),
getCachedEntries<"navigation">(
"navigation",
{ type: "header", language: lang },
"sort",
undefined,
undefined,
undefined,
{ lookup: NAVIGATION_CONTENT_LOOKUP }
),
getCachedEntries<"navigation">(
"navigation",
{ type: "footer", language: lang },
"sort",
undefined,
undefined,
undefined,
{ lookup: NAVIGATION_CONTENT_LOOKUP }
),
])
headerNav = headerEntries[0] || null
footerNav = footerEntries[0] || null
// Load content for current path
const contentEntries = await getCachedEntries<"content">("content", {
lang,
path: routePath,
active: true,
})
const contentEntries = await getCachedEntries<"content">(
"content",
{
lang,
path: routePath,
active: true,
},
"sort",
undefined,
undefined,
undefined,
{ lookup: CONTENT_MEDIA_LOOKUP }
)
if (contentEntries.length > 0) {
contentEntry = contentEntries[0]
@@ -157,6 +236,9 @@
{#if contentEntry?.meta?.description}
<meta name="description" content={contentEntry.meta.description} />
{/if}
{#if contentEntry?.meta?.keywords?.length}
<meta name="keywords" content={contentEntry.meta.keywords.join(", ")} />
{/if}
</svelte:head>
<LoadingBar />
@@ -168,6 +250,15 @@
? 'bg-white/80 backdrop-blur-lg shadow-sm'
: 'bg-transparent'}"
>
<button
type="button"
aria-controls="main-content"
onclick={handleSkipToMainContent}
class="sr-only absolute left-4 top-4 z-50 rounded bg-brand-600 px-4 py-2 text-sm font-semibold text-white focus:not-sr-only"
>
{$_("skipToMainContent")}
</button>
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<!-- Logo -->
<a
@@ -184,7 +275,7 @@
{#if headerNav?.elements}
{#each headerNav.elements as item}
<a
href={localizedPath(item.page || "/")}
href={resolveNavigationHref(item)}
class="text-sm font-medium transition-colors duration-300 hover:text-brand-500 {scrolled
? 'text-gray-700'
: 'text-white/90'}"
@@ -248,7 +339,7 @@
{#if headerNav?.elements}
{#each headerNav.elements as item}
<a
href={localizedPath(item.page || "/")}
href={resolveNavigationHref(item)}
class="block text-gray-700 text-lg font-medium hover:text-brand-600 py-2"
>
{item.name}
@@ -273,7 +364,7 @@
</header>
<!-- ── Main Content ──────────────────────────────────────────── -->
<main>
<main id="main-content" tabindex="-1">
{#if loading}
<div class="min-h-screen flex items-center justify-center">
<div class="w-8 h-8 border-4 border-brand-200 border-t-brand-600 rounded-full animate-spin"></div>
@@ -319,7 +410,7 @@
</a>
{:else}
<a
href={localizedPath(item.page || "/")}
href={resolveNavigationHref(item)}
class="text-sm hover:text-white transition-colors"
>
{item.name}
+96 -2
View File
@@ -1,4 +1,40 @@
import type { SvelteComponent } from "svelte"
import { mount, unmount, type Component, type SvelteComponent } from "svelte"
import BlockRenderer from "./blocks/BlockRenderer.svelte"
const previewCssUrl = new URL("./index.css", import.meta.url).toString()
type BlockRenderContext = {
namespace?: string
apiBase?: string
projectBase?: string
}
type BlockHandle = {
update(row: Record<string, any>, context?: BlockRenderContext): void
destroy(): void
}
type BlockDefinition = {
render(container: HTMLElement | ShadowRoot, row: Record<string, any>, context?: BlockRenderContext): BlockHandle
css?: string[]
previewStyles?: Record<string, string>
label?: string
icon?: string
color?: string
}
type BlockPresentation = {
label: string
icon: string
color: string
}
function getAdminPreviewProps(props?: { [key: string]: any }) {
return {
isAdminPreview: true,
...(props || {}),
}
}
function getRenderedElement(
component: typeof SvelteComponent,
@@ -33,13 +69,71 @@ function getRenderedElement(
new component({
target: target,
props: options?.props,
props: getAdminPreviewProps(options?.props),
})
return el
}
function createContentBlockDefinition(presentation: BlockPresentation): BlockDefinition {
return {
css: [previewCssUrl],
label: presentation.label,
icon: presentation.icon,
color: presentation.color,
previewStyles: {
"background-color": "white",
position: "relative",
},
render(container, row, context) {
const target = document.createElement("div")
target.dataset.adminPreview = "true"
container.appendChild(target)
let mountedComponent = mount(BlockRenderer as Component<any>, {
target,
props: {
blocks: [row as ContentBlockEntry],
isAdminPreview: true,
},
})
return {
update(nextRow) {
unmount(mountedComponent)
target.innerHTML = ""
mountedComponent = mount(BlockRenderer as Component<any>, {
target,
props: {
blocks: [nextRow as ContentBlockEntry],
isAdminPreview: true,
},
})
},
destroy() {
unmount(mountedComponent)
target.remove()
},
}
},
}
}
const blockRegistry = {
hero: createContentBlockDefinition({ label: "Hero", icon: "image", color: "#1d4ed8" }),
features: createContentBlockDefinition({ label: "Features", icon: "view_quilt", color: "#0f766e" }),
richtext: createContentBlockDefinition({ label: "Richtext", icon: "article", color: "#7c3aed" }),
accordion: createContentBlockDefinition({ label: "Accordion", icon: "expand", color: "#b45309" }),
"contact-form": createContentBlockDefinition({
label: "Contact Form",
icon: "mail",
color: "#be185d",
}),
}
export {
getAdminPreviewProps,
getRenderedElement,
blockRegistry,
// pass also required svelte components here
}
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
let { icon = "lightning", className = "" }: { icon?: ContentFeatureBoxEntry["icon"]; className?: string } = $props()
</script>
{#if icon === "palette"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.01 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.49-9-10-9z"
></path>
<circle cx="7.5" cy="11.5" r="1.5"></circle>
<circle cx="10.5" cy="7.5" r="1.5"></circle>
<circle cx="14.5" cy="7.5" r="1.5"></circle>
<circle cx="17.5" cy="11.5" r="1.5"></circle>
</svg>
{:else if icon === "database"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 22v-5"></path>
<path d="M9 8V2"></path>
<path d="M15 8V2"></path>
<path d="M18 8v5a6 6 0 0 1-12 0V8h12z"></path>
</svg>
{:else if icon === "globe"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20"></path>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
{:else if icon === "monitor"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2"></rect>
<path d="M8 21h8"></path>
<path d="M12 17v4"></path>
</svg>
{:else if icon === "flask"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 2h6"></path>
<path
d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.578A1 1 0 0 0 5.598 22h12.804a1 1 0 0 0 .878-1.422l-5.069-10.155A2 2 0 0 1 14 9.527V2"
></path>
</svg>
{:else}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
{/if}
+17 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { reveal } from "../lib/actions/reveal"
import FeatureIcon from "./FeatureIcon.svelte"
let { block }: { block: ContentBlockEntry } = $props()
@@ -41,10 +42,22 @@
</div>
{/if}
{#if block.text}
<div class="prose max-w-none" use:reveal={{ delay: 200 }}>
{@html block.text}
{#if block.featureBoxes?.length}
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3" use:reveal={{ delay: 200 }}>
{#each block.featureBoxes as item}
<article class="feature-card">
<div class="feature-icon">
<FeatureIcon icon={item.icon} className="w-10 h-10" />
</div>
{#if item.title}
<h3>{item.title}</h3>
{/if}
{#if item.text}
<p>{item.text}</p>
{/if}
</article>
{/each}
</div>
{/if}
</div>
</section>
</section>
+13 -4
View File
@@ -1,10 +1,16 @@
<script lang="ts">
import { reveal } from "../lib/actions/reveal"
import { spaLink } from "../lib/navigation"
import MedialibImage from "../widgets/MedialibImage.svelte"
let { block }: { block: ContentBlockEntry } = $props()
const hasImage = $derived(block.heroImage?.externalUrl || block.heroImage?.image)
const resolvedHeroImage = $derived(
block.heroImage?._lookup?.image ||
(block._lookup?.["heroImage.image"] as MedialibEntry | null | undefined) ||
null
)
const hasImage = $derived(!!resolvedHeroImage?.file?.src)
const isAnchorLink = $derived(block.callToAction?.buttonLink?.startsWith("#"))
</script>
@@ -17,9 +23,12 @@
<!-- Background image -->
{#if hasImage}
<div class="absolute inset-0 z-0">
{#if block.heroImage?.externalUrl}
<img src={block.heroImage.externalUrl} alt="" class="w-full h-full object-cover" loading="lazy" />
{/if}
<MedialibImage
id={block.heroImage?.image || resolvedHeroImage?.id || resolvedHeroImage?._id || ""}
entry={resolvedHeroImage}
noPlaceholder
style="width:100%;height:100%;object-fit:cover;"
/>
<div class="absolute inset-0 bg-linear-to-b from-brand-950/80 via-brand-900/70 to-brand-950/90"></div>
</div>
{:else}
+13 -8
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import { reveal } from "../lib/actions/reveal"
import MedialibImage from "../widgets/MedialibImage.svelte"
let { block }: { block: ContentBlockEntry } = $props()
const resolvedImage = $derived(block._lookup?.image || null)
const paddingTop = $derived(
block.padding?.top === "lg"
? "pt-20"
@@ -22,10 +25,10 @@
: "pb-4"
)
const hasImage = $derived(block.externalImageUrl || block.image)
const hasImage = $derived(block.image || resolvedImage)
const imageOnRight = $derived(block.imagePosition === "right")
const imageOnLeft = $derived(block.imagePosition === "left")
const showImage = $derived(hasImage && (imageOnRight || imageOnLeft))
const showImage = $derived(hasImage && !!resolvedImage?.file?.src && (imageOnRight || imageOnLeft))
</script>
<section data-block="richtext" class="richtext-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}>
@@ -54,12 +57,14 @@
</div>
<div class:order-1={imageOnLeft} class="relative">
<div class="rounded-2xl overflow-hidden shadow-xl shadow-brand-900/10">
<img
src={block.externalImageUrl || ""}
alt={block.headline || ""}
class="w-full h-auto object-cover aspect-4/3"
loading="lazy"
/>
{#if block.image || resolvedImage}
<MedialibImage
id={block.image || resolvedImage?.id || resolvedImage?._id || ""}
entry={resolvedImage}
noPlaceholder
style="width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;"
/>
{/if}
</div>
<!-- Decorative gradient behind image -->
<div
+4
View File
@@ -60,6 +60,10 @@
}
/* ── Prose styling for CMS richtext blocks ───────────────────────── */
.prose {
color: var(--color-gray-900, #111827);
}
.prose h2 {
font-size: 1.5rem;
font-weight: 700;
+5
View File
@@ -20,6 +20,11 @@ export interface RevealOptions {
}
export function reveal(node: HTMLElement, options: RevealOptions = {}) {
if (node.closest("[data-admin-preview='true']")) {
node.classList.add("reveal", "revealed")
return
}
if (typeof IntersectionObserver === "undefined") return
const { delay = 0, threshold = 0.15, once = true } = options
+44 -18
View File
@@ -16,6 +16,47 @@ export { locale, isLoading, addMessages } from "svelte-i18n"
register("de", () => import("./locales/de.json"))
register("en", () => import("./locales/en.json"))
let isI18nInitialized = false
let syncSubscriptionsInitialized = false
function ensureI18nInitialized(initialLocale: SupportedLanguage = DEFAULT_LANGUAGE): void {
if (isI18nInitialized) {
return
}
init({
fallbackLocale: DEFAULT_LANGUAGE,
initialLocale,
})
isI18nInitialized = true
}
function ensureLocaleSync(): void {
if (syncSubscriptionsInitialized) {
return
}
// Keep svelte-i18n locale and selectedLanguage store in sync
locale.subscribe((newLocale) => {
if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) {
selectedLanguage.set(newLocale as SupportedLanguage)
}
})
selectedLanguage.subscribe((newLang) => {
const currentLocale = get(locale)
if (newLang && newLang !== currentLocale) {
locale.set(newLang)
}
})
syncSubscriptionsInitialized = true
}
ensureI18nInitialized()
ensureLocaleSync()
/**
* Determine the initial locale from URL, browser, or fallback.
*/
@@ -50,26 +91,11 @@ function getInitialLocale(url?: string): SupportedLanguage {
export async function setupI18n(url?: string): Promise<void> {
const initialLocale = getInitialLocale(url)
init({
fallbackLocale: DEFAULT_LANGUAGE,
initialLocale,
})
ensureI18nInitialized(initialLocale)
ensureLocaleSync()
selectedLanguage.set(initialLocale)
// Keep svelte-i18n locale and selectedLanguage store in sync
locale.subscribe((newLocale) => {
if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) {
selectedLanguage.set(newLocale as SupportedLanguage)
}
})
selectedLanguage.subscribe((newLang) => {
const currentLocale = get(locale)
if (newLang && newLang !== currentLocale) {
locale.set(newLang)
}
})
locale.set(initialLocale)
await waitLocale()
}
+1
View File
@@ -50,6 +50,7 @@
},
"welcome": "Willkommen",
"language": "Sprache",
"skipToMainContent": "Zum Hauptinhalt springen",
"scrollToTop": "Nach oben",
"loading": "Laden…"
}
+1
View File
@@ -50,6 +50,7 @@
},
"welcome": "Welcome",
"language": "Language",
"skipToMainContent": "Skip to main content",
"scrollToTop": "Scroll to top",
"loading": "Loading…"
}
+123 -5
View File
@@ -14,11 +14,50 @@
// Add new collections here as needed.
// ---------------------------------------------------------------------------
import contentData from "../../mocking/content.json"
import medialibData from "../../mocking/medialib.json"
import navigationData from "../../mocking/navigation.json"
type EJsonObjectId = {
$oid: string
}
const mockRegistry: Record<string, Record<string, unknown>[]> = {
content: contentData as Record<string, unknown>[],
navigation: navigationData as Record<string, unknown>[],
content: normalizeMockCollection(contentData as Record<string, unknown>[]),
medialib: normalizeMockCollection(medialibData as Record<string, unknown>[]),
navigation: normalizeMockCollection(navigationData as Record<string, unknown>[]),
}
function isEJsonObjectId(value: unknown): value is EJsonObjectId {
return !!value && typeof value === "object" && "$oid" in value && typeof (value as EJsonObjectId).$oid === "string"
}
function normalizeMockCollection(entries: Record<string, unknown>[]): Record<string, unknown>[] {
return entries.map((entry) => normalizeMockValue(entry))
}
function normalizeMockValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => normalizeMockValue(item)) as T
}
if (!value || typeof value !== "object") {
return value
}
const normalized: Record<string, unknown> = {}
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
normalized[key] = normalizeMockValue(nestedValue)
}
if (isEJsonObjectId(normalized._id)) {
normalized._id = normalized._id.$oid
}
if (typeof normalized._id === "string" && normalized.id === undefined) {
normalized.id = normalized._id
}
return normalized as T
}
// ---------------------------------------------------------------------------
@@ -49,9 +88,10 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u
// --- Single-item retrieval ---
if (itemId) {
const item = sourceData.find((e) => e.id === itemId || e._id === itemId)
const resultItem = item ? applyLookups(cloneEntry(item), options) : null
return {
data: item ?? null,
count: item ? 1 : 0,
data: resultItem,
count: resultItem ? 1 : 0,
buildTime: null,
}
}
@@ -79,6 +119,8 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u
results = results.slice(0, options.limit)
}
results = results.map((entry) => applyLookups(cloneEntry(entry), options))
// Projection
if (options?.projection) {
results = applyProjection(results, options.projection)
@@ -184,6 +226,81 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
}, obj)
}
function cloneEntry<T>(entry: T): T {
return JSON.parse(JSON.stringify(entry)) as T
}
function applyLookups(entry: Record<string, unknown>, options?: ApiOptions): Record<string, unknown> {
const lookupSpecs = parseLookupSpecs(options)
if (!lookupSpecs.length) return entry
for (const spec of lookupSpecs) {
const [fieldPath, collection] = spec.split(":")
if (!fieldPath || !collection) continue
const lookupSource = mockRegistry[collection]
if (!lookupSource) continue
applyLookupAtPath(entry, fieldPath.split("."), lookupSource)
}
return entry
}
function parseLookupSpecs(options?: ApiOptions): string[] {
const rawLookup = [options?.lookup, options?.params?.lookup]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean)
return Array.from(new Set(rawLookup))
}
function applyLookupAtPath(
current: Record<string, unknown>,
pathSegments: string[],
lookupSource: Record<string, unknown>[]
): void {
const [segment, ...rest] = pathSegments
if (!segment) return
const value = current[segment]
if (rest.length === 0) {
current._lookup = (current._lookup as Record<string, unknown> | undefined) || {}
;(current._lookup as Record<string, unknown>)[segment] = resolveLookupValue(value, lookupSource)
return
}
if (Array.isArray(value)) {
value.forEach((item) => {
if (item && typeof item === "object") {
applyLookupAtPath(item as Record<string, unknown>, rest, lookupSource)
}
})
return
}
if (value && typeof value === "object") {
applyLookupAtPath(value as Record<string, unknown>, rest, lookupSource)
}
}
function resolveLookupValue(value: unknown, lookupSource: Record<string, unknown>[]): unknown {
if (Array.isArray(value)) {
return value.map((entryId) => resolveLookupById(entryId, lookupSource))
}
return resolveLookupById(value, lookupSource)
}
function resolveLookupById(value: unknown, lookupSource: Record<string, unknown>[]): Record<string, unknown> | null {
if (typeof value !== "string") return null
return lookupSource.find((entry) => entry.id === value || entry._id === value) || null
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
@@ -244,7 +361,8 @@ function applyProjection(data: Record<string, unknown>[], projectionStr: string)
if (field in entry) result[field] = entry[field]
}
// Always include id/_id
if (entry.id !== undefined) result.id = entry.id
if (typeof entry.id === "string") result.id = entry.id
else if (typeof entry._id === "string") result.id = entry._id
if (entry._id !== undefined) result._id = entry._id
return result
}
+23 -77
View File
@@ -1,47 +1,10 @@
<script lang="ts">
import { getDBEntries, getDBEntry } from "../lib/api"
import { apiBaseURL } from "../config"
import { apiBaseOverride } from "../lib/store"
import { get } from "svelte/store"
// Medialib cache (module-level)
const medialibCache: { [id: string]: MedialibEntry } = {}
let loadQueue: string[] = []
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function processQueue() {
if (loadQueue.length) {
const _ids = [...loadQueue]
loadQueue = []
const entries = await getDBEntries(
"medialib",
{ _id: { $in: _ids } },
"_id",
undefined,
undefined,
"public"
)
entries.forEach((entry: MedialibEntry) => {
if (entry.id) medialibCache[entry.id] = entry
})
}
}
async function loadMedialibEntry(id: string): Promise<MedialibEntry> {
if (medialibCache[id]) return medialibCache[id]
loadQueue.push(id)
await new Promise<void>((resolve) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
await processQueue()
resolve()
}, 50)
})
return medialibCache[id]
}
import { currentLanguage, DEFAULT_LANGUAGE } from "../lib/i18n"
interface Props {
id: string
entry?: MedialibEntry | null
filter?: string | null
noPlaceholder?: boolean
caption?: string
@@ -54,6 +17,7 @@
let {
id,
entry = null,
filter = null,
noPlaceholder = false,
caption = "",
@@ -64,11 +28,10 @@
style = "",
}: Props = $props()
let loading = $state(true)
let entry = $state<MedialibEntry | null>(null)
let fileSrc = $state<string | null>(null)
let imgEl = $state<HTMLImageElement | null>(null)
let currentFilter = $state<string>("l-webp")
const effectiveId = $derived(entry?.id || entry?._id || id || "")
const fileSrc = $derived(resolveFileSrc(entry?.file?.src, entry?.id || entry?._id || effectiveId))
// Sync explicit filter prop reactively
$effect(() => {
@@ -94,34 +57,14 @@
return false
}
async function loadFile() {
if (!id) return
loading = true
entry = null
fileSrc = null
try {
const _apiBase = get(apiBaseOverride) || apiBaseURL
entry =
typeof window !== "undefined"
? await loadMedialibEntry(id)
: await getDBEntry("medialib", { _id: id }, "public")
if (entry?.file?.src) {
fileSrc = _apiBase + "medialib/" + id + "/" + entry.file.src
}
} catch (e) {
console.error(e)
}
loading = false
function resolveFileSrc(src: string | undefined, entryId: string | undefined): string | null {
if (!src) return null
if (/^(https?:)?\/\//.test(src) || src.startsWith("/")) return src
if (!entryId) return null
const normalizedApiBase = apiBaseURL.replace(/\/+$/, "")
return `${normalizedApiBase}/medialib/${entryId}/${src.replace(/^\/+/, "")}`
}
// SSR: fire-and-forget — $effect does NOT run during SSR.
// loadFile() internally checks if id is set.
if (typeof window === "undefined") loadFile()
$effect(() => {
if (id) loadFile()
})
// ResizeObserver: only when no explicit filter and raster image
$effect(() => {
const el = imgEl
@@ -147,21 +90,24 @@
if (filter) return src + `?filter=${filter}`
return src + `?filter=${currentFilter}`
}
function resolveLocalizedText(value: string | LocalizedText | undefined, lang: string): string {
if (!value) return ""
if (typeof value === "string") return value
return value[lang] || value[DEFAULT_LANGUAGE] || Object.values(value).find((entry) => !!entry) || ""
}
</script>
{#if id}
{#if loading}
{#if !noPlaceholder}
<img src="/assets/img/placeholder-image.svg" alt="loading" />
{/if}
{:else if entry && fileSrc}
{#if effectiveId}
{#if entry && fileSrc}
{#if showCaption && caption}
<figure>
<picture>
<img
bind:this={imgEl}
src={getSrc(fileSrc, entry)}
alt={entry.alt || ""}
alt={resolveLocalizedText(entry.alt, $currentLanguage)}
data-entry-id={id}
loading={lazy ? "lazy" : undefined}
{style}
@@ -176,7 +122,7 @@
<img
bind:this={imgEl}
src={getSrc(fileSrc, entry)}
alt={entry.alt || ""}
alt={resolveLocalizedText(entry.alt, $currentLanguage)}
data-entry-id={id}
loading={lazy ? "lazy" : undefined}
{style}
@@ -185,7 +131,7 @@
{/if}
{:else if !noPlaceholder}
<picture>
<img src="/assets/img/placeholder-image.svg" alt="not found" data-entry-id={id} />
<img src="/assets/img/placeholder-image.svg" alt="not found" data-entry-id={effectiveId} />
</picture>
{/if}
{/if}