✨ feat: add new contact form, hero, features, and richtext blocks; implement scroll-reveal action and update styles
- Introduced ContactFormBlock, FeaturesBlock, HeroBlock, and RichtextBlock components. - Implemented a scroll-reveal action for animations on element visibility. - Enhanced CSS styles for better theming and prose formatting. - Added localization support for new components and updated existing translations. - Created e2e tests for demo pages including contact form validation and navigation. - Added a video tour showcasing the demo pages and interactions.
This commit is contained in:
1
.env
1
.env
@@ -22,3 +22,4 @@ STAGING_URL=https://dev-__PROJECT_NAME__.staging.testversion.online
|
|||||||
CODING_URL=https://__PROJECT_NAME__.code.testversion.online
|
CODING_URL=https://__PROJECT_NAME__.code.testversion.online
|
||||||
|
|
||||||
#START_SCRIPT=:ssr
|
#START_SCRIPT=:ssr
|
||||||
|
MOCK=1
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ playwright-report/
|
|||||||
playwright/.cache/
|
playwright/.cache/
|
||||||
visual-review/
|
visual-review/
|
||||||
video-tours/output/
|
video-tours/output/
|
||||||
|
.playwright-mcp/
|
||||||
.yarn/*
|
.yarn/*
|
||||||
!.yarn/cache
|
!.yarn/cache
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
|
|||||||
Binary file not shown.
20
AGENTS.md
20
AGENTS.md
@@ -67,3 +67,23 @@ Tibi CMS starter template — Svelte 5 SPA with esbuild, SSR via goja, and Playw
|
|||||||
- Avoid introducing new dependencies unless absolutely necessary.
|
- Avoid introducing new dependencies unless absolutely necessary.
|
||||||
- Respect a11y and localization best practices; optimize for WCAG AA standards.
|
- Respect a11y and localization best practices; optimize for WCAG AA standards.
|
||||||
- Check the problems tab for any errors or warnings in the code.
|
- Check the problems tab for any errors or warnings in the code.
|
||||||
|
- **Zero warnings policy**: After making changes, always run `yarn validate` and check the IDE problems tab. Fix all TypeScript, Svelte, and Tailwind warnings — the codebase must stay warning-free.
|
||||||
|
- When Tailwind `suggestCanonicalClasses` warnings appear, always fix the **source** `.svelte`/`.ts`/`.css` file — never the compiled output.
|
||||||
|
|
||||||
|
### Tailwind CSS 4 canonical classes
|
||||||
|
|
||||||
|
This project uses Tailwind CSS 4. Always use the canonical v4 syntax:
|
||||||
|
|
||||||
|
| Legacy (v3) | Canonical (v4) |
|
||||||
|
| ---------------------- | ---------------------- |
|
||||||
|
| `bg-gradient-to-*` | `bg-linear-to-*` |
|
||||||
|
| `aspect-[4/3]` | `aspect-4/3` |
|
||||||
|
| `!bg-brand-600` | `bg-brand-600!` |
|
||||||
|
| `hover:!bg-brand-700` | `hover:bg-brand-700!` |
|
||||||
|
| `!rounded-xl` | `rounded-xl!` |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Gradient directions use `bg-linear-to-{direction}` instead of `bg-gradient-to-{direction}`.
|
||||||
|
- Bare-ratio aspect values like `aspect-4/3` replace arbitrary `aspect-[4/3]`.
|
||||||
|
- The `!important` modifier is a **suffix** (`class!`) instead of a **prefix** (`!class`).
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
Starter Kit für SPAs(s) `;)` mit Svelte und TibiCMS inkl. SSR
|
Starter Kit für SPAs(s) `;)` mit Svelte und TibiCMS inkl. SSR
|
||||||
|
|
||||||
|
## Neues Projekt starten
|
||||||
|
|
||||||
|
Nachdem du dieses Repository als Vorlage geklont hast, passe die Platzhalter an dein Projekt an:
|
||||||
|
|
||||||
|
1. **`.env`**: Ersetze `__PROJECT_NAME__` mit deinem Projektnamen (z.B. `mein-projekt`).
|
||||||
|
Die folgenden URLs werden automatisch abgeleitet:
|
||||||
|
- `CODING_URL=https://mein-projekt.code.testversion.online`
|
||||||
|
- `STAGING_URL=https://dev-mein-projekt.staging.testversion.online`
|
||||||
|
2. **`frontend/spa.html`** / **`api/templates/spa.html`**: Ersetze `__PROJECT_TITLE__` mit dem Seitentitel.
|
||||||
|
3. **`api/config.yml.env`**: Passe `ADMIN_TOKEN`, Datenbank-Name und weitere Secrets an.
|
||||||
|
4. **`docker-compose-local.yml`**: Suche nach `project_name__` und ersetze mit deinem Projektnamen (Container-Benennung).
|
||||||
|
5. **Demo-Inhalte entfernen**: Die Demo-Seite besteht aus diesen Dateien, die für ein echtes Projekt entfernt/ersetzt werden können:
|
||||||
|
- `frontend/src/blocks/` — Block-Komponenten (HeroBlock, FeaturesBlock, RichtextBlock, AccordionBlock, ContactFormBlock, BlockRenderer, NotFound)
|
||||||
|
- `frontend/mocking/content.json` — Demo-Mockdaten für Content
|
||||||
|
- `frontend/mocking/navigation.json` — Demo-Mockdaten für Navigation
|
||||||
|
- `api/collections/content.yml` — Content-Collection-Konfiguration
|
||||||
|
- `api/collections/navigation.yml` — Navigation-Collection-Konfiguration
|
||||||
|
- `tests/e2e/demo.spec.ts` — Demo-E2E-Tests
|
||||||
|
- `video-tours/tours/demo-showcase.tour.ts` — Demo-Video-Tour
|
||||||
|
6. **`frontend/src/App.svelte`**: Passe Header, Footer und Content-Loading an dein Datenmodell an.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Platzhalter ersetzen (Beispiel für Linux/Mac):
|
||||||
|
sed -i 's/__PROJECT_NAME__/mein-projekt/g' .env
|
||||||
|
sed -i 's/__PROJECT_TITLE__/Mein Projekt/g' frontend/spa.html api/templates/spa.html
|
||||||
|
```
|
||||||
|
|
||||||
## Wozu?
|
## Wozu?
|
||||||
|
|
||||||
Via Svelte wird eine SPA (Single-Page-App) programmiert. Dazu wird der Code einmal für den Browser aufgebreitet und außerdem für den Server kompiliert und transpiliert. Der Server-Code wird in einem tibi-server SSR-Hook (server side rendering) eingebunden und generiert dort fertiges HTML anhand der aktuelle Route für SEO und optimierte Ladezeiten.
|
Via Svelte wird eine SPA (Single-Page-App) programmiert. Dazu wird der Code einmal für den Browser aufgebreitet und außerdem für den Server kompiliert und transpiliert. Der Server-Code wird in einem tibi-server SSR-Hook (server side rendering) eingebunden und generiert dort fertiges HTML anhand der aktuelle Route für SEO und optimierte Ladezeiten.
|
||||||
|
|||||||
171
api/collections/content.yml
Normal file
171
api/collections/content.yml
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
########################################################################
|
||||||
|
# Content collection — CMS-managed pages with pagebuilder blocks
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
name: content
|
||||||
|
meta:
|
||||||
|
label: { de: "Inhalte", en: "Content" }
|
||||||
|
muiIcon: article
|
||||||
|
rowIdentTpl: { twig: "{{ name }}" }
|
||||||
|
|
||||||
|
views:
|
||||||
|
- type: simpleList
|
||||||
|
mediaQuery: "(max-width: 600px)"
|
||||||
|
primaryText: name
|
||||||
|
secondaryText: lang
|
||||||
|
tertiaryText: path
|
||||||
|
- type: table
|
||||||
|
columns:
|
||||||
|
- name
|
||||||
|
- source: lang
|
||||||
|
filter: true
|
||||||
|
- source: type
|
||||||
|
filter: true
|
||||||
|
- source: path
|
||||||
|
- source: active
|
||||||
|
filter: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
public:
|
||||||
|
methods:
|
||||||
|
get: true
|
||||||
|
user:
|
||||||
|
methods:
|
||||||
|
get: true
|
||||||
|
post: true
|
||||||
|
put: true
|
||||||
|
delete: true
|
||||||
|
|
||||||
|
fields:
|
||||||
|
- name: active
|
||||||
|
type: boolean
|
||||||
|
meta:
|
||||||
|
label: { de: "Aktiv", en: "Active" }
|
||||||
|
- name: type
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Typ", en: "Type" }
|
||||||
|
- name: lang
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Sprache", en: "Language" }
|
||||||
|
- name: translationKey
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Übersetzungsschlüssel", en: "Translation Key" }
|
||||||
|
- name: name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Name", en: "Name" }
|
||||||
|
- name: path
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Pfad", en: "Path" }
|
||||||
|
- name: alternativePaths
|
||||||
|
type: object[]
|
||||||
|
meta:
|
||||||
|
label: { de: "Alternative Pfade", en: "Alternative Paths" }
|
||||||
|
subFields:
|
||||||
|
- name: path
|
||||||
|
type: string
|
||||||
|
- name: thumbnail
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Vorschaubild", en: "Thumbnail" }
|
||||||
|
- name: teaserText
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Teasertext", en: "Teaser Text" }
|
||||||
|
- name: blocks
|
||||||
|
type: object[]
|
||||||
|
meta:
|
||||||
|
label: { de: "Inhaltsblöcke", en: "Content Blocks" }
|
||||||
|
subFields:
|
||||||
|
- name: hide
|
||||||
|
type: boolean
|
||||||
|
- name: type
|
||||||
|
type: string
|
||||||
|
- name: headline
|
||||||
|
type: string
|
||||||
|
- name: headlineH1
|
||||||
|
type: boolean
|
||||||
|
- name: subline
|
||||||
|
type: string
|
||||||
|
- name: tagline
|
||||||
|
type: string
|
||||||
|
- name: anchorId
|
||||||
|
type: string
|
||||||
|
- name: containerWidth
|
||||||
|
type: string
|
||||||
|
- name: background
|
||||||
|
type: object
|
||||||
|
subFields:
|
||||||
|
- name: color
|
||||||
|
type: string
|
||||||
|
- name: image
|
||||||
|
type: string
|
||||||
|
- name: padding
|
||||||
|
type: object
|
||||||
|
subFields:
|
||||||
|
- name: top
|
||||||
|
type: string
|
||||||
|
- name: bottom
|
||||||
|
type: string
|
||||||
|
- name: callToAction
|
||||||
|
type: object
|
||||||
|
subFields:
|
||||||
|
- name: buttonText
|
||||||
|
type: string
|
||||||
|
- name: buttonLink
|
||||||
|
type: string
|
||||||
|
- name: buttonTarget
|
||||||
|
type: string
|
||||||
|
- name: heroImage
|
||||||
|
type: object
|
||||||
|
subFields:
|
||||||
|
- name: image
|
||||||
|
type: string
|
||||||
|
- name: text
|
||||||
|
type: string
|
||||||
|
- name: imagePosition
|
||||||
|
type: string
|
||||||
|
- name: imageRounded
|
||||||
|
type: string
|
||||||
|
- name: image
|
||||||
|
type: string
|
||||||
|
- name: accordionItems
|
||||||
|
type: object[]
|
||||||
|
subFields:
|
||||||
|
- name: question
|
||||||
|
type: string
|
||||||
|
- name: answer
|
||||||
|
type: string
|
||||||
|
- name: open
|
||||||
|
type: boolean
|
||||||
|
- name: imageGallery
|
||||||
|
type: object
|
||||||
|
subFields:
|
||||||
|
- name: images
|
||||||
|
type: object[]
|
||||||
|
subFields:
|
||||||
|
- name: image
|
||||||
|
type: string
|
||||||
|
- name: caption
|
||||||
|
type: string
|
||||||
|
- name: showCaption
|
||||||
|
type: boolean
|
||||||
|
- name: showImageCaption
|
||||||
|
type: boolean
|
||||||
|
- name: imageCaption
|
||||||
|
type: string
|
||||||
|
- name: meta
|
||||||
|
type: object
|
||||||
|
meta:
|
||||||
|
label: { de: "SEO", en: "SEO" }
|
||||||
|
subFields:
|
||||||
|
- name: title
|
||||||
|
type: string
|
||||||
|
- name: description
|
||||||
|
type: string
|
||||||
|
- name: keywords
|
||||||
|
type: string
|
||||||
68
api/collections/navigation.yml
Normal file
68
api/collections/navigation.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
########################################################################
|
||||||
|
# Navigation collection — header and footer navigation entries
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
name: navigation
|
||||||
|
meta:
|
||||||
|
label: { de: "Navigation", en: "Navigation" }
|
||||||
|
muiIcon: menu
|
||||||
|
rowIdentTpl: { twig: "{{ type }} ({{ language }})" }
|
||||||
|
|
||||||
|
views:
|
||||||
|
- type: simpleList
|
||||||
|
mediaQuery: "(max-width: 600px)"
|
||||||
|
primaryText: type
|
||||||
|
secondaryText: language
|
||||||
|
- type: table
|
||||||
|
columns:
|
||||||
|
- source: type
|
||||||
|
filter: true
|
||||||
|
- source: language
|
||||||
|
filter: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
public:
|
||||||
|
methods:
|
||||||
|
get: true
|
||||||
|
user:
|
||||||
|
methods:
|
||||||
|
get: true
|
||||||
|
post: true
|
||||||
|
put: true
|
||||||
|
delete: true
|
||||||
|
|
||||||
|
fields:
|
||||||
|
- name: language
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Sprache", en: "Language" }
|
||||||
|
- name: type
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Typ", en: "Type" }
|
||||||
|
helperText: { de: "header oder footer", en: "header or footer" }
|
||||||
|
- name: elements
|
||||||
|
type: object[]
|
||||||
|
meta:
|
||||||
|
label: { de: "Elemente", en: "Elements" }
|
||||||
|
subFields:
|
||||||
|
- name: name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Bezeichnung", en: "Label" }
|
||||||
|
- name: page
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Seite (Content-ID)", en: "Page (Content ID)" }
|
||||||
|
- name: external
|
||||||
|
type: boolean
|
||||||
|
meta:
|
||||||
|
label: { de: "Externer Link", en: "External Link" }
|
||||||
|
- name: externalUrl
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Externe URL", en: "External URL" }
|
||||||
|
- name: hash
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
label: { de: "Anker", en: "Anchor" }
|
||||||
@@ -9,6 +9,8 @@ meta:
|
|||||||
"<link rel='stylesheet' href='" + $projectBase + "_/assets/fonts/fonts.css?t=" + $project?.updateTime + "'>"
|
"<link rel='stylesheet' href='" + $projectBase + "_/assets/fonts/fonts.css?t=" + $project?.updateTime + "'>"
|
||||||
|
|
||||||
collections:
|
collections:
|
||||||
|
- !include collections/content.yml
|
||||||
|
- !include collections/navigation.yml
|
||||||
- !include collections/ssr.yml
|
- !include collections/ssr.yml
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "home",
|
"id": "home-de",
|
||||||
"_id": "home",
|
"_id": "home-de",
|
||||||
"active": true,
|
"active": true,
|
||||||
"type": "page",
|
"type": "page",
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
@@ -11,49 +11,231 @@
|
|||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"type": "hero",
|
"type": "hero",
|
||||||
"headline": "Willkommen",
|
"headline": "Moderne Webprojekte. Blitzschnell umgesetzt.",
|
||||||
"headlineH1": true,
|
"headlineH1": true,
|
||||||
"subline": "Demo-Startseite des Starter-Templates",
|
"subline": "Tibi CMS Starter — Svelte 5, Tailwind CSS 4, SSR und eine API, die einfach funktioniert.",
|
||||||
|
"containerWidth": "full",
|
||||||
|
"callToAction": {
|
||||||
|
"buttonText": "Features entdecken",
|
||||||
|
"buttonLink": "#features",
|
||||||
|
"buttonTarget": ""
|
||||||
|
},
|
||||||
"heroImage": {
|
"heroImage": {
|
||||||
"image": "demo-hero"
|
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "features",
|
||||||
|
"headline": "Was dieses Template kann",
|
||||||
|
"tagline": "Features",
|
||||||
|
"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'>⚡</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'>🎨</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'>🔌</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'>🌍</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'>🖥️</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'>🧪</div><h3>Playwright Tests</h3><p>E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go.</p></div></div>"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "richtext",
|
"type": "richtext",
|
||||||
"headline": "Über uns",
|
"headline": "So funktioniert's",
|
||||||
"text": "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
|
"tagline": "Workflow",
|
||||||
|
"anchorId": "workflow",
|
||||||
|
"padding": { "top": "lg", "bottom": "sm" },
|
||||||
|
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
|
||||||
|
"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>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "accordion",
|
||||||
|
"headline": "Häufige Fragen",
|
||||||
|
"tagline": "FAQ",
|
||||||
|
"anchorId": "faq",
|
||||||
|
"padding": { "top": "sm", "bottom": "lg" },
|
||||||
|
"accordionItems": [
|
||||||
|
{
|
||||||
|
"question": "Wie starte ich ein neues Projekt mit diesem Template?",
|
||||||
|
"answer": "<p>Klone das Repository, passe <code>.env</code> an und starte mit <code>make docker-up && make docker-start</code>. Die Demo-Inhalte kannst du einfach durch echte Inhalte ersetzen.</p>",
|
||||||
|
"open": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Brauche ich einen laufenden Tibi-Server für die Entwicklung?",
|
||||||
|
"answer": "<p>Nein! Mit <code>MOCK=1</code> in der <code>.env</code> werden API-Aufrufe gegen lokale JSON-Dateien aufgelöst. So kannst du Frontend-Features ohne Backend entwickeln.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Wie füge ich eine neue Seite hinzu?",
|
||||||
|
"answer": "<p>Erstelle einen neuen Content-Eintrag in der Collection (oder in <code>mocking/content.json</code> für Mock-Modus) mit dem gewünschten Pfad und Blöcken. Die App rendert ihn automatisch über den BlockRenderer.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Kann ich eigene Block-Typen erstellen?",
|
||||||
|
"answer": "<p>Ja! Erstelle eine neue Svelte-Komponente und registriere den Typ im BlockRenderer. Das Type-Interface <code>ContentBlockEntry</code> in <code>global.d.ts</code> kannst du entsprechend erweitern.</p>"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "Startseite",
|
"title": "Tibi Svelte Starter — Modernes CMS-Template",
|
||||||
"description": "Demo-Startseite"
|
"description": "Svelte 5, Tailwind CSS 4, SSR, i18n und Playwright-Tests — das perfekte Starterkit.",
|
||||||
|
"keywords": "svelte, tibi, cms, starter, template"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "about",
|
"id": "home-en",
|
||||||
"_id": "about",
|
"_id": "home-en",
|
||||||
|
"active": true,
|
||||||
|
"type": "page",
|
||||||
|
"lang": "en",
|
||||||
|
"translationKey": "home",
|
||||||
|
"name": "Home",
|
||||||
|
"path": "/",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "hero",
|
||||||
|
"headline": "Modern web projects. Lightning fast.",
|
||||||
|
"headlineH1": true,
|
||||||
|
"subline": "Tibi CMS Starter — Svelte 5, Tailwind CSS 4, SSR and an API that just works.",
|
||||||
|
"containerWidth": "full",
|
||||||
|
"callToAction": {
|
||||||
|
"buttonText": "Explore features",
|
||||||
|
"buttonLink": "#features",
|
||||||
|
"buttonTarget": ""
|
||||||
|
},
|
||||||
|
"heroImage": {
|
||||||
|
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "features",
|
||||||
|
"headline": "What this template offers",
|
||||||
|
"tagline": "Features",
|
||||||
|
"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'>⚡</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'>🎨</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'>🔌</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'>🌍</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'>🖥️</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'>🧪</div><h3>Playwright Tests</h3><p>E2E, API, visual regression and video tours — all preconfigured and ready to go.</p></div></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "richtext",
|
||||||
|
"headline": "How it works",
|
||||||
|
"tagline": "Workflow",
|
||||||
|
"anchorId": "workflow",
|
||||||
|
"padding": { "top": "lg", "bottom": "sm" },
|
||||||
|
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
|
||||||
|
"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>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "accordion",
|
||||||
|
"headline": "Frequently Asked Questions",
|
||||||
|
"tagline": "FAQ",
|
||||||
|
"anchorId": "faq",
|
||||||
|
"padding": { "top": "sm", "bottom": "lg" },
|
||||||
|
"accordionItems": [
|
||||||
|
{
|
||||||
|
"question": "How do I start a new project with this template?",
|
||||||
|
"answer": "<p>Clone the repository, adjust <code>.env</code> and start with <code>make docker-up && make docker-start</code>. Simply replace the demo content with your real content.</p>",
|
||||||
|
"open": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Do I need a running Tibi server for development?",
|
||||||
|
"answer": "<p>No! With <code>MOCK=1</code> in <code>.env</code>, API calls are resolved against local JSON files. This lets you develop frontend features without a backend.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How do I add a new page?",
|
||||||
|
"answer": "<p>Create a new content entry in the collection (or in <code>mocking/content.json</code> for mock mode) with the desired path and blocks. The app renders it automatically via the BlockRenderer.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Can I create custom block types?",
|
||||||
|
"answer": "<p>Yes! Create a new Svelte component and register the type in BlockRenderer. You can extend the <code>ContentBlockEntry</code> type interface in <code>global.d.ts</code> accordingly.</p>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "about-de",
|
||||||
|
"_id": "about-de",
|
||||||
"active": true,
|
"active": true,
|
||||||
"type": "page",
|
"type": "page",
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
"translationKey": "about",
|
"translationKey": "about",
|
||||||
"name": "Über uns",
|
"name": "Über das Template",
|
||||||
"path": "/ueber-uns",
|
"path": "/ueber-uns",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"type": "richtext",
|
"type": "hero",
|
||||||
"headline": "Über uns",
|
"headline": "Über das Template",
|
||||||
"headlineH1": true,
|
"headlineH1": true,
|
||||||
"text": "<p>Wir sind ein Demo-Unternehmen.</p>"
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "richtext",
|
||||||
|
"headline": "Warum dieses Template?",
|
||||||
|
"padding": { "top": "lg", "bottom": "md" },
|
||||||
|
"text": "<p>Das Tibi Svelte Starter vereint bewährte Patterns aus dutzenden Projekten in einem sofort einsetzbaren Fundament:</p><ul><li><strong>API-Layer</strong> mit Request-Deduplication, Loading-States und Caching</li><li><strong>Widget-Bibliothek</strong> mit Button, Input, Select, Carousel, Pagination und mehr</li><li><strong>Block-Rendering</strong> für CMS-gesteuerte Seiten mit beliebig erweiterbaren Typen</li><li><strong>Testing-Setup</strong> mit Playwright für E2E, API und visuelle Regressionstests</li></ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "richtext",
|
||||||
|
"headline": "Technologie-Stack",
|
||||||
|
"padding": { "top": "md", "bottom": "lg" },
|
||||||
|
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
|
||||||
|
"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>"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "Über uns",
|
"title": "Über das Template — Tibi Svelte Starter",
|
||||||
"description": "Erfahren Sie mehr über uns"
|
"description": "Architektur und Tech-Stack des Tibi Svelte Starter Templates.",
|
||||||
|
"keywords": "svelte, über uns, template, architektur"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "contact",
|
"id": "about-en",
|
||||||
"_id": "contact",
|
"_id": "about-en",
|
||||||
|
"active": true,
|
||||||
|
"type": "page",
|
||||||
|
"lang": "en",
|
||||||
|
"translationKey": "about",
|
||||||
|
"name": "About the Template",
|
||||||
|
"path": "/about",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "hero",
|
||||||
|
"headline": "About the Template",
|
||||||
|
"headlineH1": true,
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "richtext",
|
||||||
|
"headline": "Why this template?",
|
||||||
|
"padding": { "top": "lg", "bottom": "md" },
|
||||||
|
"text": "<p>The Tibi Svelte Starter combines proven patterns from dozens of projects into an immediately usable foundation:</p><ul><li><strong>API layer</strong> with request deduplication, loading states and caching</li><li><strong>Widget library</strong> with Button, Input, Select, Carousel, Pagination and more</li><li><strong>Block rendering</strong> for CMS-driven pages with extensible types</li><li><strong>Testing setup</strong> with Playwright for E2E, API and visual regression tests</li></ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "richtext",
|
||||||
|
"headline": "Technology Stack",
|
||||||
|
"padding": { "top": "md", "bottom": "lg" },
|
||||||
|
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
|
||||||
|
"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>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"title": "About the Template — Tibi Svelte Starter",
|
||||||
|
"description": "Architecture and tech stack of the Tibi Svelte Starter Template.",
|
||||||
|
"keywords": "svelte, about, template, architecture"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "contact-de",
|
||||||
|
"_id": "contact-de",
|
||||||
"active": true,
|
"active": true,
|
||||||
"type": "page",
|
"type": "page",
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
@@ -62,15 +244,57 @@
|
|||||||
"path": "/kontakt",
|
"path": "/kontakt",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"type": "richtext",
|
"type": "hero",
|
||||||
"headline": "Kontakt",
|
"headline": "Kontakt",
|
||||||
"headlineH1": true,
|
"headlineH1": true,
|
||||||
"text": "<p>Schreiben Sie uns eine Nachricht.</p>"
|
"subline": "Fragen, Feedback oder Projektanfragen? Schreib uns!",
|
||||||
|
"containerWidth": "full",
|
||||||
|
"heroImage": {
|
||||||
|
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contact-form",
|
||||||
|
"headline": "Nachricht senden",
|
||||||
|
"padding": { "top": "lg", "bottom": "lg" }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "Kontakt",
|
"title": "Kontakt — Tibi Svelte Starter",
|
||||||
"description": "Kontaktieren Sie uns"
|
"description": "Nimm Kontakt mit uns auf.",
|
||||||
|
"keywords": "kontakt, anfrage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "contact-en",
|
||||||
|
"_id": "contact-en",
|
||||||
|
"active": true,
|
||||||
|
"type": "page",
|
||||||
|
"lang": "en",
|
||||||
|
"translationKey": "contact",
|
||||||
|
"name": "Contact",
|
||||||
|
"path": "/contact",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "hero",
|
||||||
|
"headline": "Contact",
|
||||||
|
"headlineH1": true,
|
||||||
|
"subline": "Questions, feedback or project inquiries? Get in touch!",
|
||||||
|
"containerWidth": "full",
|
||||||
|
"heroImage": {
|
||||||
|
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contact-form",
|
||||||
|
"headline": "Send a message",
|
||||||
|
"padding": { "top": "lg", "bottom": "lg" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"title": "Contact — Tibi Svelte Starter",
|
||||||
|
"description": "Get in touch with us.",
|
||||||
|
"keywords": "contact, inquiry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -5,18 +5,20 @@
|
|||||||
"language": "de",
|
"language": "de",
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"elements": [
|
"elements": [
|
||||||
{
|
{ "name": "Startseite", "page": "/" },
|
||||||
"name": "Startseite",
|
{ "name": "Über uns", "page": "/ueber-uns" },
|
||||||
"page": "home"
|
{ "name": "Kontakt", "page": "/kontakt" }
|
||||||
},
|
]
|
||||||
{
|
},
|
||||||
"name": "Über uns",
|
{
|
||||||
"page": "about"
|
"id": "header-en",
|
||||||
},
|
"_id": "header-en",
|
||||||
{
|
"language": "en",
|
||||||
"name": "Kontakt",
|
"type": "header",
|
||||||
"page": "contact"
|
"elements": [
|
||||||
}
|
{ "name": "Home", "page": "/" },
|
||||||
|
{ "name": "About", "page": "/about" },
|
||||||
|
{ "name": "Contact", "page": "/contact" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -25,14 +27,22 @@
|
|||||||
"language": "de",
|
"language": "de",
|
||||||
"type": "footer",
|
"type": "footer",
|
||||||
"elements": [
|
"elements": [
|
||||||
{
|
{ "name": "Startseite", "page": "/" },
|
||||||
"name": "Impressum",
|
{ "name": "Über uns", "page": "/ueber-uns" },
|
||||||
"page": "imprint"
|
{ "name": "Kontakt", "page": "/kontakt" },
|
||||||
},
|
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
|
||||||
{
|
]
|
||||||
"name": "Datenschutz",
|
},
|
||||||
"page": "privacy"
|
{
|
||||||
}
|
"id": "footer-en",
|
||||||
|
"_id": "footer-en",
|
||||||
|
"language": "en",
|
||||||
|
"type": "footer",
|
||||||
|
"elements": [
|
||||||
|
{ "name": "Home", "page": "/" },
|
||||||
|
{ "name": "About", "page": "/about" },
|
||||||
|
{ "name": "Contact", "page": "/contact" },
|
||||||
|
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { metricCall } from "./config"
|
import { metricCall } from "./config"
|
||||||
import { location } from "./lib/store"
|
import { location, mobileMenuOpen, currentContentEntry } from "./lib/store"
|
||||||
import { _, locale } from "./lib/i18n/index"
|
import { _, locale } from "./lib/i18n/index"
|
||||||
import LoadingBar from "./widgets/LoadingBar.svelte"
|
import LoadingBar from "./widgets/LoadingBar.svelte"
|
||||||
import ToastContainer from "./widgets/ToastContainer.svelte"
|
import ToastContainer from "./widgets/ToastContainer.svelte"
|
||||||
import DebugFooterInfo from "./widgets/DebugFooterInfo.svelte"
|
import DebugFooterInfo from "./widgets/DebugFooterInfo.svelte"
|
||||||
|
import BlockRenderer from "./blocks/BlockRenderer.svelte"
|
||||||
|
import NotFound from "./blocks/NotFound.svelte"
|
||||||
import { initScrollRestoration } from "./lib/navigation"
|
import { initScrollRestoration } from "./lib/navigation"
|
||||||
|
import { getCachedEntries } from "./lib/api"
|
||||||
import {
|
import {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
LANGUAGE_LABELS,
|
LANGUAGE_LABELS,
|
||||||
@@ -15,8 +18,9 @@
|
|||||||
getBrowserLanguage,
|
getBrowserLanguage,
|
||||||
extractLanguageFromPath,
|
extractLanguageFromPath,
|
||||||
DEFAULT_LANGUAGE,
|
DEFAULT_LANGUAGE,
|
||||||
getRoutePath,
|
stripLanguageFromPath,
|
||||||
} from "./lib/i18n"
|
} from "./lib/i18n"
|
||||||
|
|
||||||
export let url = ""
|
export let url = ""
|
||||||
|
|
||||||
initScrollRestoration()
|
initScrollRestoration()
|
||||||
@@ -31,7 +35,6 @@
|
|||||||
push: false,
|
push: false,
|
||||||
pop: false,
|
pop: false,
|
||||||
}
|
}
|
||||||
// Set svelte-i18n locale from URL for SSR rendering
|
|
||||||
const lang = extractLanguageFromPath(l[0]) || DEFAULT_LANGUAGE
|
const lang = extractLanguageFromPath(l[0]) || DEFAULT_LANGUAGE
|
||||||
$locale = lang
|
$locale = lang
|
||||||
}
|
}
|
||||||
@@ -58,63 +61,278 @@
|
|||||||
"x-ssr-skip": "204",
|
"x-ssr-skip": "204",
|
||||||
"x-ssr-ref": ref,
|
"x-ssr-ref": ref,
|
||||||
"x-ssr-res": `${window.innerWidth}x${window.innerHeight}`,
|
"x-ssr-res": `${window.innerWidth}x${window.innerHeight}`,
|
||||||
// no cache
|
|
||||||
"cache-control": "no-cache, no-store, must-revalidate",
|
"cache-control": "no-cache, no-store, must-revalidate",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Content loading ──────────────────────────────────────────
|
||||||
|
let contentEntry = $state<ContentEntry | null>(null)
|
||||||
|
let headerNav = $state<NavigationEntry | null>(null)
|
||||||
|
let footerNav = $state<NavigationEntry | null>(null)
|
||||||
|
let loading = $state(true)
|
||||||
|
let notFound = $state(false)
|
||||||
|
|
||||||
|
// Header scroll detection
|
||||||
|
let scrolled = $state(false)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
() => {
|
||||||
|
scrolled = window.scrollY > 20
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close mobile menu on navigation
|
||||||
|
$effect(() => {
|
||||||
|
$location.path // subscribe
|
||||||
|
$mobileMenuOpen = false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadContent(lang: string, routePath: string) {
|
||||||
|
loading = true
|
||||||
|
notFound = false
|
||||||
|
contentEntry = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load navigation
|
||||||
|
const [headerEntries, footerEntries] = await Promise.all([
|
||||||
|
getCachedEntries<"navigation">("navigation", { type: "header", language: lang }),
|
||||||
|
getCachedEntries<"navigation">("navigation", { type: "footer", language: lang }),
|
||||||
|
])
|
||||||
|
headerNav = headerEntries[0] || null
|
||||||
|
footerNav = footerEntries[0] || null
|
||||||
|
|
||||||
|
// Load content for current path
|
||||||
|
const contentEntries = await getCachedEntries<"content">("content", {
|
||||||
|
lang,
|
||||||
|
path: routePath,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (contentEntries.length > 0) {
|
||||||
|
contentEntry = contentEntries[0]
|
||||||
|
$currentContentEntry = {
|
||||||
|
translationKey: contentEntry.translationKey,
|
||||||
|
lang: contentEntry.lang,
|
||||||
|
path: contentEntry.path,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notFound = true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[App] Failed to load content:", err)
|
||||||
|
notFound = true
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-load content when path or language changes
|
||||||
|
$effect(() => {
|
||||||
|
const lang = $currentLanguage
|
||||||
|
const routePath = stripLanguageFromPath($location.path)
|
||||||
|
loadContent(lang, routePath || "/")
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LoadingBar />
|
<LoadingBar />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
<header class="text-white p-4 bg-red-900">
|
<!-- ── Sticky Header with glassmorphism ──────────────────────── -->
|
||||||
<div class="container mx-auto flex flex-wrap items-center justify-between gap-2">
|
<header
|
||||||
<a href={localizedPath("/")} class="text-xl font-bold shrink-0">Tibi Svelte Starter</a>
|
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300 {scrolled
|
||||||
<nav class="flex items-center gap-4">
|
? 'bg-white/80 backdrop-blur-lg shadow-sm'
|
||||||
<ul class="hidden sm:flex space-x-4">
|
: 'bg-transparent'}"
|
||||||
<li><a href={localizedPath("/")} class="hover:underline">{$_("nav.home")}</a></li>
|
>
|
||||||
<li><a href={localizedPath("/about")} class="hover:underline">{$_("nav.about")}</a></li>
|
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<li><a href={localizedPath("/contact")} class="hover:underline">{$_("nav.contact")}</a></li>
|
<!-- Logo -->
|
||||||
</ul>
|
<a
|
||||||
<div class="flex space-x-2 text-sm sm:border-l sm:border-white/30 sm:pl-4">
|
href={localizedPath("/")}
|
||||||
{#each SUPPORTED_LANGUAGES as lang}
|
class="text-xl font-display font-bold transition-colors duration-300 {scrolled
|
||||||
|
? 'text-gray-900'
|
||||||
|
: 'text-white'}"
|
||||||
|
>
|
||||||
|
{#if scrolled}<span class="text-gradient">Tibi</span>{:else}Tibi{/if} Starter
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Desktop Nav -->
|
||||||
|
<nav class="hidden md:flex items-center gap-8">
|
||||||
|
{#if headerNav?.elements}
|
||||||
|
{#each headerNav.elements as item}
|
||||||
<a
|
<a
|
||||||
href={getLanguageSwitchUrl(lang)}
|
href={localizedPath(item.page || "/")}
|
||||||
class="hover:underline px-1"
|
class="text-sm font-medium transition-colors duration-300 hover:text-brand-500 {scrolled
|
||||||
class:font-bold={$currentLanguage === lang}
|
? 'text-gray-700'
|
||||||
class:opacity-60={$currentLanguage !== lang}
|
: 'text-white/90'}"
|
||||||
>
|
>
|
||||||
{LANGUAGE_LABELS[lang]}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Language Switcher -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 border-l pl-6 transition-colors duration-300 {scrolled
|
||||||
|
? 'border-gray-200'
|
||||||
|
: 'border-white/20'}"
|
||||||
|
>
|
||||||
|
{#each SUPPORTED_LANGUAGES as lang}
|
||||||
|
{#if $currentLanguage === lang}
|
||||||
|
<a
|
||||||
|
href={getLanguageSwitchUrl(lang)}
|
||||||
|
class="text-xs font-semibold uppercase px-2 py-1 rounded transition-all duration-300 bg-brand-600 text-white"
|
||||||
|
>
|
||||||
|
{lang}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={getLanguageSwitchUrl(lang)}
|
||||||
|
class="text-xs font-semibold uppercase px-2 py-1 rounded transition-all duration-300 hover:text-brand-500 {scrolled
|
||||||
|
? 'text-gray-500'
|
||||||
|
: 'text-white/50'}"
|
||||||
|
>
|
||||||
|
{lang}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile Hamburger -->
|
||||||
|
<button
|
||||||
|
class="md:hidden p-2 rounded-lg transition-colors {scrolled ? 'text-gray-900' : 'text-white'}"
|
||||||
|
onclick={() => ($mobileMenuOpen = !$mobileMenuOpen)}
|
||||||
|
aria-label="Menu"
|
||||||
|
aria-expanded={$mobileMenuOpen}
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{#if $mobileMenuOpen}
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"
|
||||||
|
></path>
|
||||||
|
{:else}
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
></path>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile nav -->
|
|
||||||
<nav class="sm:hidden mt-2 border-t border-white/20 pt-2">
|
<!-- Mobile Menu Overlay -->
|
||||||
<ul class="flex space-x-4 text-sm">
|
{#if $mobileMenuOpen}
|
||||||
<li><a href={localizedPath("/")} class="hover:underline">{$_("nav.home")}</a></li>
|
<div class="md:hidden bg-white border-t border-gray-100 shadow-lg">
|
||||||
<li><a href={localizedPath("/about")} class="hover:underline">{$_("nav.about")}</a></li>
|
<nav class="max-w-6xl mx-auto px-6 py-4 space-y-3">
|
||||||
<li><a href={localizedPath("/contact")} class="hover:underline">{$_("nav.contact")}</a></li>
|
{#if headerNav?.elements}
|
||||||
</ul>
|
{#each headerNav.elements as item}
|
||||||
</nav>
|
<a
|
||||||
|
href={localizedPath(item.page || "/")}
|
||||||
|
class="block text-gray-700 text-lg font-medium hover:text-brand-600 py-2"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2 pt-3 border-t border-gray-100">
|
||||||
|
{#each SUPPORTED_LANGUAGES as lang}
|
||||||
|
<a
|
||||||
|
href={getLanguageSwitchUrl(lang)}
|
||||||
|
class="text-xs font-semibold uppercase px-3 py-1.5 rounded {$currentLanguage === lang
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'text-gray-500 bg-gray-100'}"
|
||||||
|
>
|
||||||
|
{LANGUAGE_LABELS[lang]}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container mx-auto p-4">
|
<!-- ── Main Content ──────────────────────────────────────────── -->
|
||||||
{#if getRoutePath($location.path) === "/about"}
|
<main>
|
||||||
<h1 class="text-2xl font-bold mb-4">{$_("page.about.title")}</h1>
|
{#if loading}
|
||||||
<p>{$_("page.about.text")}</p>
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
{:else if getRoutePath($location.path) === "/contact"}
|
<div class="w-8 h-8 border-4 border-brand-200 border-t-brand-600 rounded-full animate-spin"></div>
|
||||||
<h1 class="text-2xl font-bold mb-4">{$_("page.contact.title")}</h1>
|
</div>
|
||||||
<p>{$_("page.contact.text")}</p>
|
{:else if notFound}
|
||||||
{:else}
|
<div class="pt-16">
|
||||||
<h1 class="text-2xl font-bold mb-4">{$_("page.home.title")}</h1>
|
<NotFound />
|
||||||
<p>{$_("page.home.text")}</p>
|
</div>
|
||||||
|
{:else if contentEntry?.blocks}
|
||||||
|
<div class="page-enter">
|
||||||
|
<BlockRenderer blocks={contentEntry.blocks} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="text-center p-2">
|
<!-- ── Footer ────────────────────────────────────────────────── -->
|
||||||
<DebugFooterInfo />
|
<footer class="bg-gray-900 text-gray-400">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-12">
|
||||||
|
<div class="grid md:grid-cols-3 gap-8 mb-8">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white font-display font-bold text-lg mb-3">Tibi Starter</h3>
|
||||||
|
<p class="text-sm leading-relaxed">
|
||||||
|
{$_("footer.madeWith")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Nav -->
|
||||||
|
{#if footerNav?.elements}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-white font-semibold text-sm uppercase tracking-wider mb-3">Navigation</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each footerNav.elements as item}
|
||||||
|
<li>
|
||||||
|
{#if item.external}
|
||||||
|
<a
|
||||||
|
href={item.externalUrl || "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{item.name} ↗
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={localizedPath(item.page || "/")}
|
||||||
|
class="text-sm hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Language -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-white font-semibold text-sm uppercase tracking-wider mb-3">{$_("language")}</h4>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each SUPPORTED_LANGUAGES as lang}
|
||||||
|
<a
|
||||||
|
href={getLanguageSwitchUrl(lang)}
|
||||||
|
class="text-sm px-3 py-1.5 rounded transition-all {$currentLanguage === lang
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'hover:text-white'}"
|
||||||
|
>
|
||||||
|
{LANGUAGE_LABELS[lang]}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom bar -->
|
||||||
|
<div class="border-t border-gray-800 pt-6 flex flex-col sm:flex-row justify-between items-center gap-4 text-sm">
|
||||||
|
<p>© {new Date().getFullYear()} Tibi Svelte Starter. {$_("footer.rights")}</p>
|
||||||
|
<DebugFooterInfo />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
91
frontend/src/blocks/AccordionBlock.svelte
Normal file
91
frontend/src/blocks/AccordionBlock.svelte
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { reveal } from "../lib/actions/reveal"
|
||||||
|
import { untrack } from "svelte"
|
||||||
|
|
||||||
|
let { block }: { block: ContentBlockEntry } = $props()
|
||||||
|
|
||||||
|
const paddingTop = $derived(
|
||||||
|
block.padding?.top === "lg"
|
||||||
|
? "pt-20"
|
||||||
|
: block.padding?.top === "md"
|
||||||
|
? "pt-12"
|
||||||
|
: block.padding?.top === "sm"
|
||||||
|
? "pt-8"
|
||||||
|
: "pt-4"
|
||||||
|
)
|
||||||
|
const paddingBottom = $derived(
|
||||||
|
block.padding?.bottom === "lg"
|
||||||
|
? "pb-20"
|
||||||
|
: block.padding?.bottom === "md"
|
||||||
|
? "pb-12"
|
||||||
|
: block.padding?.bottom === "sm"
|
||||||
|
? "pb-8"
|
||||||
|
: "pb-4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track open/closed state per item — intentionally capture initial prop value only
|
||||||
|
let openItems = $state<boolean[]>(untrack(() => (block.accordionItems || []).map((item) => !!item.open)))
|
||||||
|
|
||||||
|
function toggle(index: number) {
|
||||||
|
openItems = openItems.map((open, i) => (i === index ? !open : open))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
data-block="accordion"
|
||||||
|
class="accordion-section {paddingTop} {paddingBottom} bg-gray-50"
|
||||||
|
id={block.anchorId || undefined}
|
||||||
|
>
|
||||||
|
<div class="max-w-3xl mx-auto px-6">
|
||||||
|
{#if block.tagline}
|
||||||
|
<div use:reveal>
|
||||||
|
<span class="inline-block text-brand-500 text-sm font-semibold tracking-widest uppercase mb-3">
|
||||||
|
{block.tagline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if block.headline}
|
||||||
|
<div use:reveal={{ delay: 100 }}>
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-10">
|
||||||
|
{block.headline}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each block.accordionItems || [] as item, i}
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
|
||||||
|
use:reveal={{ delay: 150 + i * 80 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-6 py-5 flex items-center justify-between gap-4 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
onclick={() => toggle(i)}
|
||||||
|
aria-expanded={openItems[i]}
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-gray-900 text-lg">
|
||||||
|
{item.question}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-brand-500 shrink-0 transition-transform duration-300"
|
||||||
|
class:rotate-180={openItems[i]}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if openItems[i]}
|
||||||
|
<div class="px-6 pb-5 prose max-w-none text-gray-600 border-t border-gray-50">
|
||||||
|
{@html item.answer || ""}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
44
frontend/src/blocks/BlockRenderer.svelte
Normal file
44
frontend/src/blocks/BlockRenderer.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* BlockRenderer — renders ContentBlockEntry[] by type.
|
||||||
|
*
|
||||||
|
* Each block type maps to a dedicated Svelte component.
|
||||||
|
* Unknown types are silently skipped (or shown in dev mode).
|
||||||
|
* Easy to extend: add a new component import + case.
|
||||||
|
*
|
||||||
|
* DEMO: This file is part of the demo showcase.
|
||||||
|
* For real projects, adjust block types and components as needed.
|
||||||
|
*/
|
||||||
|
import HeroBlock from "./HeroBlock.svelte"
|
||||||
|
import FeaturesBlock from "./FeaturesBlock.svelte"
|
||||||
|
import RichtextBlock from "./RichtextBlock.svelte"
|
||||||
|
import AccordionBlock from "./AccordionBlock.svelte"
|
||||||
|
import ContactFormBlock from "./ContactFormBlock.svelte"
|
||||||
|
|
||||||
|
let { blocks = [] }: { blocks: ContentBlockEntry[] } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each blocks as block, i (i)}
|
||||||
|
{#if !block.hide}
|
||||||
|
{#if block.type === "hero"}
|
||||||
|
<HeroBlock {block} />
|
||||||
|
{:else if block.type === "features"}
|
||||||
|
<FeaturesBlock {block} />
|
||||||
|
{:else if block.type === "richtext"}
|
||||||
|
<RichtextBlock {block} />
|
||||||
|
{:else if block.type === "accordion"}
|
||||||
|
<AccordionBlock {block} />
|
||||||
|
{:else if block.type === "contact-form"}
|
||||||
|
<ContactFormBlock {block} />
|
||||||
|
{:else}
|
||||||
|
<!-- Unknown block type: {block.type} -->
|
||||||
|
{#if typeof window !== "undefined" && window.location?.hostname === "localhost"}
|
||||||
|
<div
|
||||||
|
class="max-w-6xl mx-auto px-6 py-4 bg-yellow-50 border border-yellow-200 rounded-lg my-4 text-sm text-yellow-800"
|
||||||
|
>
|
||||||
|
Unknown block type: <code>{block.type}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
147
frontend/src/blocks/ContactFormBlock.svelte
Normal file
147
frontend/src/blocks/ContactFormBlock.svelte
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "../lib/i18n/index"
|
||||||
|
import { reveal } from "../lib/actions/reveal"
|
||||||
|
import { addToast } from "../lib/toast"
|
||||||
|
import Form from "../widgets/Form.svelte"
|
||||||
|
import Input from "../widgets/Input.svelte"
|
||||||
|
import Select from "../widgets/Select.svelte"
|
||||||
|
import Button from "../widgets/Button.svelte"
|
||||||
|
|
||||||
|
let { block }: { block: ContentBlockEntry } = $props()
|
||||||
|
|
||||||
|
const paddingTop = $derived(
|
||||||
|
block.padding?.top === "lg"
|
||||||
|
? "pt-20"
|
||||||
|
: block.padding?.top === "md"
|
||||||
|
? "pt-12"
|
||||||
|
: block.padding?.top === "sm"
|
||||||
|
? "pt-8"
|
||||||
|
: "pt-4"
|
||||||
|
)
|
||||||
|
const paddingBottom = $derived(
|
||||||
|
block.padding?.bottom === "lg"
|
||||||
|
? "pb-20"
|
||||||
|
: block.padding?.bottom === "md"
|
||||||
|
? "pb-12"
|
||||||
|
: block.padding?.bottom === "sm"
|
||||||
|
? "pb-8"
|
||||||
|
: "pb-4"
|
||||||
|
)
|
||||||
|
|
||||||
|
let name = $state("")
|
||||||
|
let email = $state("")
|
||||||
|
let subject = $state("")
|
||||||
|
let message = $state("")
|
||||||
|
let sending = $state(false)
|
||||||
|
let formRef = $state<{ validate: () => Promise<boolean> } | null>(null)
|
||||||
|
|
||||||
|
const subjectOptions = $derived([
|
||||||
|
{ value: "", label: $_("form.selectSubject") },
|
||||||
|
{ value: "general", label: $_("form.subjects.general") },
|
||||||
|
{ value: "project", label: $_("form.subjects.project") },
|
||||||
|
{ value: "support", label: $_("form.subjects.support") },
|
||||||
|
{ value: "feedback", label: $_("form.subjects.feedback") },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!formRef) return
|
||||||
|
|
||||||
|
const isValid = await formRef.validate()
|
||||||
|
if (!isValid) return
|
||||||
|
|
||||||
|
sending = true
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||||
|
|
||||||
|
addToast($_("form.success"), "success", 5000)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
name = ""
|
||||||
|
email = ""
|
||||||
|
subject = ""
|
||||||
|
message = ""
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
data-block="contact-form"
|
||||||
|
class="contact-form-section {paddingTop} {paddingBottom}"
|
||||||
|
id={block.anchorId || undefined}
|
||||||
|
>
|
||||||
|
<div class="max-w-2xl mx-auto px-6">
|
||||||
|
{#if block.headline}
|
||||||
|
<div use:reveal>
|
||||||
|
<h2 class="text-3xl font-display font-bold text-gray-900 mb-8">
|
||||||
|
{block.headline}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div use:reveal={{ delay: 150 }}>
|
||||||
|
<Form bind:this={formRef} onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
<div class="grid sm:grid-cols-2 gap-6">
|
||||||
|
<Input
|
||||||
|
label={$_("form.name")}
|
||||||
|
hideLabel={false}
|
||||||
|
bind:value={name}
|
||||||
|
placeholder={$_("form.name")}
|
||||||
|
required
|
||||||
|
name="name"
|
||||||
|
messages={{ valueMissing: $_("form.validation.required") }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={$_("form.email")}
|
||||||
|
hideLabel={false}
|
||||||
|
bind:value={email}
|
||||||
|
placeholder={$_("form.email")}
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
name="email"
|
||||||
|
messages={{
|
||||||
|
valueMissing: $_("form.validation.required"),
|
||||||
|
typeMismatch: $_("form.validation.email"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={$_("form.subject")}
|
||||||
|
hideLabel={false}
|
||||||
|
bind:value={subject}
|
||||||
|
options={subjectOptions}
|
||||||
|
required
|
||||||
|
name="subject"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="contact-message" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{$_("form.message")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
bind:value={message}
|
||||||
|
rows="5"
|
||||||
|
required
|
||||||
|
name="message"
|
||||||
|
placeholder={$_("form.message")}
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none transition-colors resize-y"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
text={sending ? $_("loading") : $_("form.send")}
|
||||||
|
disabled={sending}
|
||||||
|
class="bg-brand-600! hover:bg-brand-700! rounded-xl!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
50
frontend/src/blocks/FeaturesBlock.svelte
Normal file
50
frontend/src/blocks/FeaturesBlock.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { reveal } from "../lib/actions/reveal"
|
||||||
|
|
||||||
|
let { block }: { block: ContentBlockEntry } = $props()
|
||||||
|
|
||||||
|
const paddingTop = $derived(
|
||||||
|
block.padding?.top === "lg"
|
||||||
|
? "pt-20"
|
||||||
|
: block.padding?.top === "md"
|
||||||
|
? "pt-12"
|
||||||
|
: block.padding?.top === "sm"
|
||||||
|
? "pt-8"
|
||||||
|
: "pt-4"
|
||||||
|
)
|
||||||
|
const paddingBottom = $derived(
|
||||||
|
block.padding?.bottom === "lg"
|
||||||
|
? "pb-20"
|
||||||
|
: block.padding?.bottom === "md"
|
||||||
|
? "pb-12"
|
||||||
|
: block.padding?.bottom === "sm"
|
||||||
|
? "pb-8"
|
||||||
|
: "pb-4"
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section data-block="features" class="features-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}>
|
||||||
|
<div class="max-w-6xl mx-auto px-6">
|
||||||
|
{#if block.tagline}
|
||||||
|
<div use:reveal>
|
||||||
|
<span class="inline-block text-brand-500 text-sm font-semibold tracking-widest uppercase mb-3">
|
||||||
|
{block.tagline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if block.headline}
|
||||||
|
<div use:reveal={{ delay: 100 }}>
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-12">
|
||||||
|
{block.headline}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if block.text}
|
||||||
|
<div class="prose max-w-none" use:reveal={{ delay: 200 }}>
|
||||||
|
{@html block.text}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
73
frontend/src/blocks/HeroBlock.svelte
Normal file
73
frontend/src/blocks/HeroBlock.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { reveal } from "../lib/actions/reveal"
|
||||||
|
|
||||||
|
let { block }: { block: ContentBlockEntry } = $props()
|
||||||
|
|
||||||
|
const hasImage = $derived(block.heroImage?.externalUrl || block.heroImage?.image)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
data-block="hero"
|
||||||
|
class="hero-section relative flex items-center justify-center overflow-hidden"
|
||||||
|
class:min-h-[70vh]={block.containerWidth === "full"}
|
||||||
|
class:min-h-[50vh]={block.containerWidth !== "full"}
|
||||||
|
>
|
||||||
|
<!-- 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}
|
||||||
|
<div class="absolute inset-0 bg-linear-to-b from-brand-950/80 via-brand-900/70 to-brand-950/90"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="absolute inset-0 bg-linear-to-br from-brand-900 via-brand-800 to-brand-950"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="relative z-10 max-w-4xl mx-auto px-6 py-20 text-center" use:reveal>
|
||||||
|
{#if block.tagline}
|
||||||
|
<span class="inline-block text-brand-300 text-sm font-semibold tracking-widest uppercase mb-4">
|
||||||
|
{block.tagline}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if block.headlineH1}
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-display font-extrabold text-white leading-tight mb-6">
|
||||||
|
{block.headline}
|
||||||
|
</h1>
|
||||||
|
{:else}
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-display font-bold text-white leading-tight mb-6">
|
||||||
|
{block.headline}
|
||||||
|
</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if block.subline}
|
||||||
|
<p class="text-lg sm:text-xl text-brand-200 max-w-2xl mx-auto mb-8 leading-relaxed">
|
||||||
|
{block.subline}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if block.callToAction?.buttonText}
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href={block.callToAction.buttonLink || "#"}
|
||||||
|
target={block.callToAction.buttonTarget || undefined}
|
||||||
|
class="inline-flex items-center gap-2 bg-brand-500 hover:bg-brand-400 text-white font-bold px-8 py-4 rounded-xl text-lg transition-all duration-300 hover:shadow-lg hover:shadow-brand-500/25 hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
{block.callToAction.buttonText}
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decorative bottom wave -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 z-10">
|
||||||
|
<svg viewBox="0 0 1440 80" fill="none" class="w-full h-auto">
|
||||||
|
<path d="M0 80V40C240 10 480 0 720 20C960 40 1200 50 1440 30V80H0Z" fill="white"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
27
frontend/src/blocks/NotFound.svelte
Normal file
27
frontend/src/blocks/NotFound.svelte
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "../lib/i18n/index"
|
||||||
|
import { localizedPath } from "../lib/i18n"
|
||||||
|
import { reveal } from "../lib/actions/reveal"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="min-h-[60vh] flex items-center justify-center bg-gray-50">
|
||||||
|
<div class="text-center px-6 py-20" use:reveal>
|
||||||
|
<div class="text-8xl mb-6 opacity-20 font-display font-black text-brand-900">404</div>
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-4">
|
||||||
|
{$_("page.notFound.title")}
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-gray-500 mb-8 max-w-md mx-auto">
|
||||||
|
{$_("page.notFound.text")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={localizedPath("/")}
|
||||||
|
class="inline-flex items-center gap-2 bg-brand-600 hover:bg-brand-700 text-white font-bold px-8 py-4 rounded-xl text-lg transition-all duration-300 hover:shadow-lg hover:shadow-brand-500/25 hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{$_("page.notFound.backHome")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
77
frontend/src/blocks/RichtextBlock.svelte
Normal file
77
frontend/src/blocks/RichtextBlock.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { reveal } from "../lib/actions/reveal"
|
||||||
|
|
||||||
|
let { block }: { block: ContentBlockEntry } = $props()
|
||||||
|
|
||||||
|
const paddingTop = $derived(
|
||||||
|
block.padding?.top === "lg"
|
||||||
|
? "pt-20"
|
||||||
|
: block.padding?.top === "md"
|
||||||
|
? "pt-12"
|
||||||
|
: block.padding?.top === "sm"
|
||||||
|
? "pt-8"
|
||||||
|
: "pt-4"
|
||||||
|
)
|
||||||
|
const paddingBottom = $derived(
|
||||||
|
block.padding?.bottom === "lg"
|
||||||
|
? "pb-20"
|
||||||
|
: block.padding?.bottom === "md"
|
||||||
|
? "pb-12"
|
||||||
|
: block.padding?.bottom === "sm"
|
||||||
|
? "pb-8"
|
||||||
|
: "pb-4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasImage = $derived(block.externalImageUrl || block.image)
|
||||||
|
const imageOnRight = $derived(block.imagePosition === "right")
|
||||||
|
const imageOnLeft = $derived(block.imagePosition === "left")
|
||||||
|
const showImage = $derived(hasImage && (imageOnRight || imageOnLeft))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section data-block="richtext" class="richtext-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}>
|
||||||
|
<div class="max-w-6xl mx-auto px-6">
|
||||||
|
{#if block.tagline}
|
||||||
|
<div use:reveal>
|
||||||
|
<span class="inline-block text-brand-500 text-sm font-semibold tracking-widest uppercase mb-3">
|
||||||
|
{block.tagline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if block.headline}
|
||||||
|
<div use:reveal={{ delay: 100 }}>
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-8">
|
||||||
|
{block.headline}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showImage}
|
||||||
|
<!-- Layout with image -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-12 items-center" use:reveal={{ delay: 200 }}>
|
||||||
|
<div class:order-2={imageOnLeft} class="prose max-w-none">
|
||||||
|
{@html block.text || ""}
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Decorative gradient behind image -->
|
||||||
|
<div
|
||||||
|
class="absolute -inset-4 -z-10 rounded-3xl bg-linear-to-br from-brand-100 to-brand-50 blur-sm"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Text-only layout -->
|
||||||
|
<div class="prose max-w-3xl" use:reveal={{ delay: 200 }}>
|
||||||
|
{@html block.text || ""}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@import 'tailwindcss';
|
@import "tailwindcss";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
@@ -9,11 +9,161 @@
|
|||||||
color utility to any element that depends on these defaults.
|
color utility to any element that depends on these defaults.
|
||||||
*/
|
*/
|
||||||
@layer base {
|
@layer base {
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
::backdrop,
|
::backdrop,
|
||||||
::file-selector-button {
|
::file-selector-button {
|
||||||
border-color: var(--color-gray-200, currentcolor);
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Custom theme tokens ─────────────────────────────────────────── */
|
||||||
|
@theme {
|
||||||
|
--color-brand-50: #f0f5ff;
|
||||||
|
--color-brand-100: #e0eaff;
|
||||||
|
--color-brand-200: #c7d7fe;
|
||||||
|
--color-brand-300: #a4bcfd;
|
||||||
|
--color-brand-400: #8098f9;
|
||||||
|
--color-brand-500: #6172f3;
|
||||||
|
--color-brand-600: #444ce7;
|
||||||
|
--color-brand-700: #3538cd;
|
||||||
|
--color-brand-800: #2d31a6;
|
||||||
|
--color-brand-900: #2d3282;
|
||||||
|
--color-brand-950: #1f235b;
|
||||||
|
|
||||||
|
--color-accent-400: #f79009;
|
||||||
|
--color-accent-500: #f59e0b;
|
||||||
|
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
--font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scroll-triggered reveal animation ───────────────────────────── */
|
||||||
|
@keyframes reveal-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(2rem);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.revealed {
|
||||||
|
animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prose styling for CMS richtext blocks ───────────────────────── */
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.prose h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.prose ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.prose ol {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.prose a {
|
||||||
|
color: var(--color-brand-600);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Smooth page transitions ─────────────────────────────────────── */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter {
|
||||||
|
animation: fade-in 0.35s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient text helper ────────────────────────────────────────── */
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, var(--color-brand-600), var(--color-brand-400));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feature cards (rendered via CMS richtext HTML) ──────────────── */
|
||||||
|
.feature-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-gray-100, #f3f4f6);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: var(--color-brand-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card .feature-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-gray-900, #111827);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
font-size: 0.938rem;
|
||||||
|
color: var(--color-gray-500, #6b7280);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prose code inline styling ───────────────────────────────────── */
|
||||||
|
.prose code {
|
||||||
|
background: var(--color-brand-50, #f0f5ff);
|
||||||
|
color: var(--color-brand-700, #3538cd);
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose strong {
|
||||||
|
color: var(--color-gray-900, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Smooth scroll for anchor links ──────────────────────────────── */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|||||||
58
frontend/src/lib/actions/reveal.ts
Normal file
58
frontend/src/lib/actions/reveal.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Scroll-reveal action — fades elements in when they enter the viewport.
|
||||||
|
*
|
||||||
|
* Usage: <div use:reveal>...</div>
|
||||||
|
* <div use:reveal={{ delay: 200, threshold: 0.2 }}>...</div>
|
||||||
|
*
|
||||||
|
* The element gets the `.reveal` CSS class on mount and `.revealed` when
|
||||||
|
* it enters the viewport. Animation is defined in style.css.
|
||||||
|
*
|
||||||
|
* SSR-safe: no-ops when IntersectionObserver is unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RevealOptions {
|
||||||
|
/** Delay in ms before the animation starts (default: 0) */
|
||||||
|
delay?: number
|
||||||
|
/** IntersectionObserver threshold 0–1 (default: 0.15) */
|
||||||
|
threshold?: number
|
||||||
|
/** Only animate once, then disconnect (default: true) */
|
||||||
|
once?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
||||||
|
if (typeof IntersectionObserver === "undefined") return
|
||||||
|
|
||||||
|
const { delay = 0, threshold = 0.15, once = true } = options
|
||||||
|
|
||||||
|
node.classList.add("reveal")
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
if (delay > 0) {
|
||||||
|
setTimeout(() => node.classList.add("revealed"), delay)
|
||||||
|
} else {
|
||||||
|
node.classList.add("revealed")
|
||||||
|
}
|
||||||
|
if (once) observer.unobserve(node)
|
||||||
|
} else if (!once) {
|
||||||
|
node.classList.remove("revealed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold }
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(node)
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newOptions: RevealOptions) {
|
||||||
|
// Options are static for simplicity — recreate if needed
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
observer.unobserve(node)
|
||||||
|
observer.disconnect()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ export const LANGUAGE_LABELS: Record<SupportedLanguage, string> = {
|
|||||||
* Example: { about: { de: "ueber-uns", en: "about" } }
|
* Example: { about: { de: "ueber-uns", en: "about" } }
|
||||||
*/
|
*/
|
||||||
export const ROUTE_TRANSLATIONS: Record<string, Record<SupportedLanguage, string>> = {
|
export const ROUTE_TRANSLATIONS: Record<string, Record<SupportedLanguage, string>> = {
|
||||||
// Add your route translations here:
|
about: { de: "ueber-uns", en: "about" },
|
||||||
// about: { de: "ueber-uns", en: "about" },
|
contact: { de: "kontakt", en: "contact" },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLocalizedRoute = (canonicalRoute: string, lang: SupportedLanguage): string => {
|
export const getLocalizedRoute = (canonicalRoute: string, lang: SupportedLanguage): string => {
|
||||||
|
|||||||
@@ -16,8 +16,40 @@
|
|||||||
"contact": {
|
"contact": {
|
||||||
"title": "Kontakt",
|
"title": "Kontakt",
|
||||||
"text": "Hier kannst du ein Kontaktformular oder Kontaktdaten anzeigen."
|
"text": "Hier kannst du ein Kontaktformular oder Kontaktdaten anzeigen."
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "Seite nicht gefunden",
|
||||||
|
"text": "Die Seite, die du suchst, existiert leider nicht.",
|
||||||
|
"backHome": "Zur Startseite"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"subject": "Betreff",
|
||||||
|
"message": "Nachricht",
|
||||||
|
"send": "Nachricht senden",
|
||||||
|
"success": "Vielen Dank! Deine Nachricht wurde gesendet.",
|
||||||
|
"error": "Leider ist ein Fehler aufgetreten. Bitte versuche es erneut.",
|
||||||
|
"selectSubject": "Bitte wählen…",
|
||||||
|
"subjects": {
|
||||||
|
"general": "Allgemeine Anfrage",
|
||||||
|
"project": "Projektanfrage",
|
||||||
|
"support": "Technischer Support",
|
||||||
|
"feedback": "Feedback"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "Dieses Feld ist erforderlich.",
|
||||||
|
"email": "Bitte gib eine gültige E-Mail-Adresse ein."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"builtWith": "Gebaut mit",
|
||||||
|
"rights": "Alle Rechte vorbehalten.",
|
||||||
|
"madeWith": "Gemacht mit ♥ und Svelte"
|
||||||
|
},
|
||||||
"welcome": "Willkommen",
|
"welcome": "Willkommen",
|
||||||
"language": "Sprache"
|
"language": "Sprache",
|
||||||
|
"scrollToTop": "Nach oben",
|
||||||
|
"loading": "Laden…"
|
||||||
}
|
}
|
||||||
@@ -16,8 +16,40 @@
|
|||||||
"contact": {
|
"contact": {
|
||||||
"title": "Contact",
|
"title": "Contact",
|
||||||
"text": "Use this page to display a contact form or contact details."
|
"text": "Use this page to display a contact form or contact details."
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "Page not found",
|
||||||
|
"text": "The page you're looking for doesn't exist.",
|
||||||
|
"backHome": "Back to Home"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"subject": "Subject",
|
||||||
|
"message": "Message",
|
||||||
|
"send": "Send message",
|
||||||
|
"success": "Thank you! Your message has been sent.",
|
||||||
|
"error": "Sorry, something went wrong. Please try again.",
|
||||||
|
"selectSubject": "Please select…",
|
||||||
|
"subjects": {
|
||||||
|
"general": "General inquiry",
|
||||||
|
"project": "Project inquiry",
|
||||||
|
"support": "Technical support",
|
||||||
|
"feedback": "Feedback"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required.",
|
||||||
|
"email": "Please enter a valid email address."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"builtWith": "Built with",
|
||||||
|
"rights": "All rights reserved.",
|
||||||
|
"madeWith": "Made with ♥ and Svelte"
|
||||||
|
},
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"scrollToTop": "Scroll to top",
|
||||||
|
"loading": "Loading…"
|
||||||
}
|
}
|
||||||
218
tests/e2e/demo.spec.ts
Normal file
218
tests/e2e/demo.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { test, expect, waitForSpaReady, navigateToRoute, clickSpaLink } from "./fixtures"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Force all scroll-reveal elements to be visible.
|
||||||
|
* The `.reveal` class starts with opacity:0 and only animates in
|
||||||
|
* when the IntersectionObserver fires — which doesn't happen
|
||||||
|
* reliably in headless Playwright screenshots/assertions.
|
||||||
|
*/
|
||||||
|
async function revealAll(page: import("@playwright/test").Page) {
|
||||||
|
await page.evaluate(() => document.querySelectorAll(".reveal").forEach((e) => e.classList.add("revealed")))
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Demo — Homepage", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/de/")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
await revealAll(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should render hero section with headline and CTA", async ({ page }) => {
|
||||||
|
const hero = page.locator("[data-block='hero']").first()
|
||||||
|
await expect(hero).toBeVisible()
|
||||||
|
|
||||||
|
const h1 = hero.locator("h1")
|
||||||
|
await expect(h1).toBeVisible()
|
||||||
|
await expect(h1).not.toBeEmpty()
|
||||||
|
|
||||||
|
const cta = hero.locator("a[href*='#']")
|
||||||
|
await expect(cta).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should render features section with cards", async ({ page }) => {
|
||||||
|
const features = page.locator("[data-block='features']")
|
||||||
|
await expect(features).toBeVisible()
|
||||||
|
|
||||||
|
const heading = features.locator("h2")
|
||||||
|
await expect(heading).toBeVisible()
|
||||||
|
|
||||||
|
const cards = features.locator(".feature-card")
|
||||||
|
await expect(cards).toHaveCount(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should render richtext section with image", async ({ page }) => {
|
||||||
|
const richtext = page.locator("[data-block='richtext']").first()
|
||||||
|
await expect(richtext).toBeVisible()
|
||||||
|
|
||||||
|
const img = richtext.locator("img")
|
||||||
|
await expect(img).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should render accordion with expandable items", async ({ page }) => {
|
||||||
|
const accordion = page.locator("[data-block='accordion']")
|
||||||
|
await expect(accordion).toBeVisible()
|
||||||
|
|
||||||
|
const buttons = accordion.locator("button")
|
||||||
|
const count = await buttons.count()
|
||||||
|
expect(count).toBeGreaterThanOrEqual(3)
|
||||||
|
|
||||||
|
// First item should be expanded by default
|
||||||
|
const firstButton = buttons.first()
|
||||||
|
await expect(firstButton).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
|
// Click a collapsed item to expand it
|
||||||
|
const secondButton = buttons.nth(1)
|
||||||
|
await expect(secondButton).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await secondButton.click()
|
||||||
|
await expect(secondButton).toHaveAttribute("aria-expanded", "true")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should have footer with navigation and language selector", async ({ page }) => {
|
||||||
|
const footer = page.locator("footer")
|
||||||
|
await expect(footer).toBeVisible()
|
||||||
|
|
||||||
|
const navLinks = footer.locator("nav a, ul a")
|
||||||
|
const linkCount = await navLinks.count()
|
||||||
|
expect(linkCount).toBeGreaterThanOrEqual(3)
|
||||||
|
|
||||||
|
// Language links in footer
|
||||||
|
const deLangLink = footer.locator('a:has-text("Deutsch")')
|
||||||
|
const enLangLink = footer.locator('a:has-text("English")')
|
||||||
|
await expect(deLangLink).toBeVisible()
|
||||||
|
await expect(enLangLink).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe("Demo — About Page", () => {
|
||||||
|
test("should load about page with hero and content", async ({ page }) => {
|
||||||
|
await page.goto("/en/about")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
await revealAll(page)
|
||||||
|
|
||||||
|
const hero = page.locator("[data-block='hero']").first()
|
||||||
|
await expect(hero).toBeVisible()
|
||||||
|
|
||||||
|
const h1 = hero.locator("h1")
|
||||||
|
await expect(h1).toContainText("About")
|
||||||
|
|
||||||
|
const richtextBlocks = page.locator("[data-block='richtext']")
|
||||||
|
const count = await richtextBlocks.count()
|
||||||
|
expect(count).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe("Demo — Contact Page", () => {
|
||||||
|
test("should load contact page with form", async ({ page }) => {
|
||||||
|
await page.goto("/en/contact")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
await revealAll(page)
|
||||||
|
|
||||||
|
const hero = page.locator("[data-block='hero']").first()
|
||||||
|
await expect(hero).toBeVisible()
|
||||||
|
|
||||||
|
const form = page.locator("[data-block='contact-form']")
|
||||||
|
await expect(form).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should have all form fields", async ({ page }) => {
|
||||||
|
await page.goto("/en/contact")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
await revealAll(page)
|
||||||
|
|
||||||
|
await expect(page.getByLabel("Name")).toBeVisible()
|
||||||
|
await expect(page.getByLabel("Email")).toBeVisible()
|
||||||
|
await expect(page.locator("select, [role='combobox']").first()).toBeVisible()
|
||||||
|
await expect(page.getByLabel("Message")).toBeVisible()
|
||||||
|
await expect(page.locator("button[type='submit'], button:has-text('Send')")).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should validate required fields", async ({ page }) => {
|
||||||
|
await page.goto("/en/contact")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
await revealAll(page)
|
||||||
|
|
||||||
|
// Click send without filling fields
|
||||||
|
const submitBtn = page.locator("button[type='submit'], button:has-text('Send')").first()
|
||||||
|
await submitBtn.click()
|
||||||
|
|
||||||
|
// Should show validation errors (form stays, no success toast)
|
||||||
|
await expect(page.locator("[data-block='contact-form']")).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe("Demo — Navigation", () => {
|
||||||
|
test("should navigate between pages via header links", async ({ page }) => {
|
||||||
|
await page.goto("/en/")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
|
||||||
|
// Navigate to About
|
||||||
|
await page.locator('header nav a[href*="/about"]').click()
|
||||||
|
await page.waitForLoadState("domcontentloaded")
|
||||||
|
expect(page.url()).toContain("/about")
|
||||||
|
await expect(page.locator("[data-block='hero'] h1")).toContainText("About")
|
||||||
|
|
||||||
|
// Navigate to Contact
|
||||||
|
await page.locator('header nav a[href*="/contact"]').click()
|
||||||
|
await page.waitForLoadState("domcontentloaded")
|
||||||
|
expect(page.url()).toContain("/contact")
|
||||||
|
await expect(page.locator("[data-block='hero'] h1")).toContainText("Contact")
|
||||||
|
|
||||||
|
// Navigate back to Home
|
||||||
|
await page.locator('header a[href="/en"]').first().click()
|
||||||
|
await page.waitForLoadState("domcontentloaded")
|
||||||
|
expect(page.url()).toMatch(/\/en\/?$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should switch language with route translation", async ({ page }) => {
|
||||||
|
// Start on English about page
|
||||||
|
await page.goto("/en/about")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
expect(page.url()).toContain("/en/about")
|
||||||
|
|
||||||
|
// Click German language link — should translate route to /de/ueber-uns
|
||||||
|
const deLink = page.locator('a[href*="/de/ueber-uns"]').first()
|
||||||
|
await expect(deLink).toBeVisible()
|
||||||
|
await deLink.click()
|
||||||
|
await page.waitForLoadState("domcontentloaded")
|
||||||
|
expect(page.url()).toContain("/de/ueber-uns")
|
||||||
|
|
||||||
|
// Verify hero updated to German
|
||||||
|
await expect(page.locator("[data-block='hero'] h1")).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should switch language on contact page with route translation", async ({ page }) => {
|
||||||
|
await page.goto("/en/contact")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
|
||||||
|
const deLink = page.locator('a[href*="/de/kontakt"]').first()
|
||||||
|
await expect(deLink).toBeVisible()
|
||||||
|
await deLink.click()
|
||||||
|
await page.waitForLoadState("domcontentloaded")
|
||||||
|
expect(page.url()).toContain("/de/kontakt")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe("Demo — 404 Page", () => {
|
||||||
|
test("should show 404 for unknown routes", async ({ page }) => {
|
||||||
|
await page.goto("/en/nonexistent-page")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
|
||||||
|
await expect(page.locator("text=404")).toBeVisible()
|
||||||
|
await expect(page.locator("h1")).toContainText("Page not found")
|
||||||
|
|
||||||
|
// Use the specific "Back to Home" link in the 404 main content, not header/footer
|
||||||
|
const homeLink = page.getByRole("link", { name: "Back to Home" })
|
||||||
|
await expect(homeLink).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should navigate back from 404 to home", async ({ page }) => {
|
||||||
|
await page.goto("/en/nonexistent-page")
|
||||||
|
await waitForSpaReady(page)
|
||||||
|
|
||||||
|
const homeLink = page.getByRole("link", { name: "Back to Home" })
|
||||||
|
await homeLink.click()
|
||||||
|
await page.waitForLoadState("domcontentloaded")
|
||||||
|
expect(page.url()).toMatch(/\/en\/?$/)
|
||||||
|
await expect(page.locator("[data-block='hero']")).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
4
types/global.d.ts
vendored
4
types/global.d.ts
vendored
@@ -83,12 +83,16 @@ interface ContentBlockEntry {
|
|||||||
}
|
}
|
||||||
heroImage?: {
|
heroImage?: {
|
||||||
image?: string
|
image?: string
|
||||||
|
/** External image URL (e.g. Unsplash) — used when no medialib ID is available */
|
||||||
|
externalUrl?: string
|
||||||
}
|
}
|
||||||
// richtext fields
|
// richtext fields
|
||||||
text?: string
|
text?: string
|
||||||
imagePosition?: "none" | "left" | "right"
|
imagePosition?: "none" | "left" | "right"
|
||||||
imageRounded?: string
|
imageRounded?: string
|
||||||
image?: string
|
image?: string
|
||||||
|
/** External image URL for richtext/generic blocks (e.g. Unsplash) */
|
||||||
|
externalImageUrl?: string
|
||||||
// accordion fields
|
// accordion fields
|
||||||
accordionItems?: {
|
accordionItems?: {
|
||||||
question?: string
|
question?: string
|
||||||
|
|||||||
166
video-tours/tours/demo-showcase.tour.ts
Normal file
166
video-tours/tours/demo-showcase.tour.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { tour } from "./fixtures"
|
||||||
|
import { moveThenClick, moveThenType, smoothScroll } from "../helpers"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video tour: Full demo showcase
|
||||||
|
*
|
||||||
|
* Walks through all demo pages, demonstrating:
|
||||||
|
* 1. Homepage — hero, features, richtext, FAQ accordion
|
||||||
|
* 2. About page — content blocks
|
||||||
|
* 3. Contact page — form interaction
|
||||||
|
* 4. Language switching (EN ↔ DE with route translation)
|
||||||
|
* 5. 404 page
|
||||||
|
*
|
||||||
|
* Run: yarn tour
|
||||||
|
*/
|
||||||
|
tour("Demo Showcase", async ({ tourPage: page }) => {
|
||||||
|
tour.setTimeout(120000)
|
||||||
|
|
||||||
|
// ── 1. Homepage (German) ─────────────────────────────────────────
|
||||||
|
await page.goto("/de/")
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
|
||||||
|
// Scroll down through hero → features
|
||||||
|
await smoothScroll(page, 500)
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
// Scroll through features
|
||||||
|
await smoothScroll(page, 1200)
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
|
||||||
|
// Scroll to richtext/workflow section
|
||||||
|
await smoothScroll(page, 2000)
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
|
||||||
|
// Scroll to FAQ accordion
|
||||||
|
await smoothScroll(page, 2800)
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
// Interact with accordion — click second item
|
||||||
|
const accordionButtons = page.locator("[data-block='accordion'] button")
|
||||||
|
const secondAccordion = accordionButtons.nth(1)
|
||||||
|
if (await secondAccordion.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, secondAccordion)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click third item
|
||||||
|
const thirdAccordion = accordionButtons.nth(2)
|
||||||
|
if (await thirdAccordion.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, thirdAccordion)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to footer
|
||||||
|
await smoothScroll(page, 9999)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
// Scroll back to top
|
||||||
|
await smoothScroll(page, 0)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
// ── 2. Navigate to About ─────────────────────────────────────────
|
||||||
|
const aboutLink = page.locator('header nav a[href*="ueber-uns"], header nav a[href*="about"]').first()
|
||||||
|
if (await aboutLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, aboutLink)
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
|
||||||
|
// Slow scroll through about content
|
||||||
|
await smoothScroll(page, 400)
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
await smoothScroll(page, 900)
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
await smoothScroll(page, 1500)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Navigate to Contact ───────────────────────────────────────
|
||||||
|
const contactLink = page.locator('header nav a[href*="kontakt"], header nav a[href*="contact"]').first()
|
||||||
|
if (await contactLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, contactLink)
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
|
||||||
|
// Scroll to form
|
||||||
|
await smoothScroll(page, 500)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
// Fill out the contact form
|
||||||
|
const nameInput = page.getByLabel("Name", { exact: false })
|
||||||
|
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenType(page, nameInput, "Max Mustermann", { delay: 60 })
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailInput = page.getByLabel("E-Mail", { exact: false }).or(page.getByLabel("Email", { exact: false }))
|
||||||
|
if (
|
||||||
|
await emailInput
|
||||||
|
.first()
|
||||||
|
.isVisible({ timeout: 2000 })
|
||||||
|
.catch(() => false)
|
||||||
|
) {
|
||||||
|
await moveThenType(page, emailInput.first(), "max@example.com", { delay: 60 })
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select subject
|
||||||
|
const selectTrigger = page.locator("select, [role='combobox']").first()
|
||||||
|
if (await selectTrigger.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, selectTrigger)
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await page.selectOption("select", { index: 2 })
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageInput = page
|
||||||
|
.getByLabel("Nachricht", { exact: false })
|
||||||
|
.or(page.getByLabel("Message", { exact: false }))
|
||||||
|
if (
|
||||||
|
await messageInput
|
||||||
|
.first()
|
||||||
|
.isVisible({ timeout: 2000 })
|
||||||
|
.catch(() => false)
|
||||||
|
) {
|
||||||
|
await moveThenType(page, messageInput.first(), "Das ist eine Testnachricht für die Video-Tour.", {
|
||||||
|
delay: 40,
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
const submitBtn = page
|
||||||
|
.locator("button[type='submit'], button:has-text('Senden'), button:has-text('Send')")
|
||||||
|
.first()
|
||||||
|
if (await submitBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, submitBtn)
|
||||||
|
await page.waitForTimeout(3000) // Wait for toast + simulated API call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Language switch ───────────────────────────────────────────
|
||||||
|
// Switch to English
|
||||||
|
const enLink = page.locator('header a[href*="/en"]').first()
|
||||||
|
if (await enLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, enLink)
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
|
||||||
|
// Scroll a bit on English page
|
||||||
|
await smoothScroll(page, 300)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
await smoothScroll(page, 0)
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. 404 page ─────────────────────────────────────────────────
|
||||||
|
await page.goto("/en/this-page-does-not-exist")
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
|
||||||
|
// Click Back to Home
|
||||||
|
const backHomeLink = page.getByRole("link", { name: /back to home/i })
|
||||||
|
if (await backHomeLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
await moveThenClick(page, backHomeLink)
|
||||||
|
await page.waitForTimeout(2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final pause on homepage
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user