✨ 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:
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Tibi Svelte Starter",
|
||||
"short_name": "Tibi Starter",
|
||||
"icons": [],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
+129
-33
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"welcome": "Willkommen",
|
||||
"language": "Sprache",
|
||||
"skipToMainContent": "Zum Hauptinhalt springen",
|
||||
"scrollToTop": "Nach oben",
|
||||
"loading": "Laden…"
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"welcome": "Welcome",
|
||||
"language": "Language",
|
||||
"skipToMainContent": "Skip to main content",
|
||||
"scrollToTop": "Scroll to top",
|
||||
"loading": "Loading…"
|
||||
}
|
||||
+123
-5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user