✨ feat: enhance accessibility with skip to main content button and improve navigation handling
🔧 fix: update navigation href resolution to include localized paths 🆕 feat: add new FeatureIcon component for feature boxes 🎨 style: improve styling for prose elements in richtext blocks 🛠️ refactor: streamline medialib image loading and caching logic 📦 chore: update mock data handling to support new medialib entries 🔄 chore: synchronize i18n initialization and locale management 📝 docs: update video tour descriptions to reflect recent changes
This commit is contained in:
@@ -391,7 +391,7 @@ function getRenderedElement(
|
||||
export { getRenderedElement }
|
||||
```
|
||||
|
||||
Build with `yarn build:admin`. The output is loaded by tibi-admin-nova as a custom module.
|
||||
Build with `yarn build`. The output includes the admin module and is loaded by tibi-admin-nova as a custom module.
|
||||
|
||||
**Use case:** Custom dashboard widgets, preview components, or field widgets that require Svelte rendering inside the admin UI.
|
||||
|
||||
|
||||
@@ -178,7 +178,6 @@ yarn validate # TypeScript check — must be warning-free
|
||||
| Type | Component | Purpose |
|
||||
| -------------- | ------------------------- | ----------------------------------------- |
|
||||
| `hero` | `HeroBlock.svelte` | Full-width hero with image, headline, CTA |
|
||||
| `features` | `FeaturesBlock.svelte` | Feature grid with icons |
|
||||
| `richtext` | `RichtextBlock.svelte` | Rich text with optional image |
|
||||
| `accordion` | `AccordionBlock.svelte` | Expandable FAQ/accordion items |
|
||||
| `contact-form` | `ContactFormBlock.svelte` | Contact form |
|
||||
|
||||
@@ -38,25 +38,23 @@ git remote rename origin template
|
||||
|
||||
Three placeholders must be replaced in the correct files:
|
||||
|
||||
| Placeholder | Files | Format | Example |
|
||||
| -------------------- | -------------------------------------- | --------------------------------------------------------- | ------------ |
|
||||
| `__PROJECT_NAME__` | `.env` | kebab-case (used for URLs, Docker containers, subdomains) | `my-project` |
|
||||
| `__TIBI_NAMESPACE__` | `.env` | snake_case (used as DB prefix and in API URLs) | `my_project` |
|
||||
| `__NAMESPACE__` | `api/config.yml`, `frontend/.htaccess` | snake_case — same value as `TIBI_NAMESPACE` | `my_project` |
|
||||
| Placeholder | Files | Format | Example |
|
||||
| -------------------- | ---------------------------------------------- | --------------------------------------------------------- | ------------ |
|
||||
| `__PROJECT_NAME__` | `.env` | kebab-case (used for URLs, Docker containers, subdomains) | `my-project` |
|
||||
| `__TIBI_NAMESPACE__` | `.env`, `api/config.yml`, `frontend/.htaccess` | snake_case (used as DB prefix and in API URLs) | `my_project` |
|
||||
|
||||
```sh
|
||||
PROJECT=my-project # kebab-case
|
||||
NAMESPACE=my_project # snake_case
|
||||
|
||||
sed -i "s/__PROJECT_NAME__/$PROJECT/g" .env
|
||||
sed -i "s/__TIBI_NAMESPACE__/$NAMESPACE/g" .env
|
||||
sed -i "s/__NAMESPACE__/$NAMESPACE/g" api/config.yml frontend/.htaccess
|
||||
sed -i "s/__TIBI_NAMESPACE__/$NAMESPACE/g" .env api/config.yml frontend/.htaccess
|
||||
```
|
||||
|
||||
**Verify each replacement:**
|
||||
|
||||
```sh
|
||||
grep -n '__PROJECT_NAME__\|__TIBI_NAMESPACE__\|__NAMESPACE__' .env api/config.yml frontend/.htaccess
|
||||
grep -n '__PROJECT_NAME__\|__TIBI_NAMESPACE__' .env api/config.yml frontend/.htaccess
|
||||
# Expected: no output (all placeholders replaced)
|
||||
```
|
||||
|
||||
@@ -73,7 +71,7 @@ STAGING_URL=https://dev-my-project.staging.testversion.online
|
||||
|
||||
- **Mixing formats**: `PROJECT` must be kebab-case (`my-project`), `NAMESPACE` must be snake_case (`my_project`). Never use kebab-case where snake_case is expected or vice versa.
|
||||
- **Forgetting `frontend/.htaccess`**: Contains the namespace for API rewrite rules. If missed, API calls from the frontend will fail silently.
|
||||
- **Forgetting `api/config.yml`**: First line is `namespace: __NAMESPACE__`. If not replaced, tibi-server won't start correctly.
|
||||
- **Forgetting `api/config.yml`**: First line is `namespace: __TIBI_NAMESPACE__`. If not replaced, tibi-server won't start correctly.
|
||||
|
||||
## Step 3 — Page title
|
||||
|
||||
@@ -146,7 +144,7 @@ For a real project, remove or replace the demo files:
|
||||
|
||||
| File/Folder | Content |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| `frontend/src/blocks/` | Demo block components (HeroBlock, FeaturesBlock, etc.) |
|
||||
| `frontend/src/blocks/` | Demo block components (HeroBlock, RichtextBlock, etc.) |
|
||||
| `frontend/mocking/content.json` | Demo mock data for content |
|
||||
| `frontend/mocking/navigation.json` | Demo mock data for navigation |
|
||||
| `api/collections/content.yml` | Content collection config |
|
||||
@@ -166,7 +164,7 @@ Then adapt `frontend/src/App.svelte` (header, footer, content loading) to your o
|
||||
```sh
|
||||
yarn build # Frontend bundle for modern browsers
|
||||
yarn build:server # SSR bundle (for tibi-server goja hooks)
|
||||
yarn build:admin # Admin modules (optional)
|
||||
yarn build # Frontend + admin module
|
||||
yarn validate # TypeScript + Svelte checks (must show 0 errors and 0 warnings)
|
||||
```
|
||||
|
||||
|
||||
@@ -22,4 +22,4 @@ STAGING_URL=https://dev-__PROJECT_NAME__.staging.testversion.online
|
||||
CODING_URL=https://__PROJECT_NAME__.code.testversion.online
|
||||
|
||||
#START_SCRIPT=:ssr
|
||||
MOCK=1
|
||||
#MOCK=1
|
||||
|
||||
@@ -39,12 +39,6 @@ jobs:
|
||||
run: |
|
||||
yarn build
|
||||
|
||||
- name: build admin
|
||||
env:
|
||||
FORCE_COLOR: "true"
|
||||
run: |
|
||||
yarn build:admin
|
||||
|
||||
- name: build ssr
|
||||
env:
|
||||
FORCE_COLOR: "true"
|
||||
|
||||
Vendored
+6
-6
@@ -5,12 +5,12 @@
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"yaml.schemas": {
|
||||
"./../../cms/tibi-types/schemas/api-config/config.json": "api/config.y*ml",
|
||||
"./../../cms/tibi-types/schemas/api-config/collection.json": "api/collections/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/api-config/field.json": "api/collections/fields/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/api-config/fieldArray.json": "api/collections/fieldLists/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/api-config/job.json": "api/jobs/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/api-config/assets.json": "api/assets/*.y*ml"
|
||||
"./../../cms/tibi-types/schemas/config/project.schema.json": "api/config.y*ml",
|
||||
"./../../cms/tibi-types/schemas/config/collection.schema.json": "api/collections/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/config/field.schema.json": "api/collections/fields/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/config/field-list.schema.json": "api/collections/fieldLists/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/config/job.schema.json": "api/jobs/*.y*ml",
|
||||
"./../../cms/tibi-types/schemas/config/asset.schema.json": "api/assets/*.y*ml"
|
||||
},
|
||||
"yaml.customTags": ["!include scalar"],
|
||||
"filewatcher.commands": [
|
||||
|
||||
@@ -44,7 +44,7 @@ docker-logs-%: ## show last X lines of docker logs
|
||||
$(DOCKER_COMPOSE) --profile tibi-dev --profile tibi --profile chisel logs --tail=$*
|
||||
|
||||
docker-pull: ## pull docker images
|
||||
$(DOCKER_COMPOSE) --profile tibi-dev --profile tibi --profile chisel pull
|
||||
$(DOCKER_COMPOSE) --profile tibi --profile chisel pull
|
||||
|
||||
docker-%:
|
||||
$(DOCKER_COMPOSE) $*
|
||||
|
||||
@@ -10,7 +10,7 @@ Starter Kit für SPAs(s) `;)` mit Svelte und TibiCMS inkl. SSR
|
||||
[ ] Repository geklont und Remotes konfiguriert
|
||||
[ ] __PROJECT_NAME__ in .env ersetzt (kebab-case)
|
||||
[ ] __TIBI_NAMESPACE__ in .env ersetzt (snake_case)
|
||||
[ ] __NAMESPACE__ in api/config.yml und frontend/.htaccess ersetzt
|
||||
[ ] __TIBI_NAMESPACE__ in api/config.yml und frontend/.htaccess ersetzt
|
||||
[ ] Keine verbleibenden __*__-Platzhalter (mit grep prüfen)
|
||||
[ ] App.svelte hat <svelte:head> mit <title>
|
||||
[ ] ADMIN_TOKEN in api/config.yml.env gesetzt
|
||||
@@ -33,8 +33,4 @@ Die Navigation innerhalb der App löst nur API-Aufrufe aus, ohne jedes Mal SSR a
|
||||
- `<html lang>` wird vom SSR-Hook (`api/hooks/ssr/get_read.js`) anhand der URL-Sprache gesetzt
|
||||
- SSR-Bundle wird mit `yarn build:server` erstellt und landet in `api/hooks/lib/app.server.js`
|
||||
|
||||
**Weiteres Build-Target:**
|
||||
|
||||
```sh
|
||||
yarn build:admin # Admin-Module
|
||||
```
|
||||
Der normale Frontend-Build `yarn build` erzeugt sowohl das Frontend-Bundle als auch das Admin-Modul `admin.mjs`.
|
||||
|
||||
+332
-48
@@ -6,24 +6,28 @@ 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
|
||||
group: content
|
||||
preview:
|
||||
label: name
|
||||
secondary: path
|
||||
tertiary: lang
|
||||
badge: type
|
||||
image: _pagebuilderThumbnail
|
||||
pagebuilder:
|
||||
screenshot:
|
||||
- field: blocks
|
||||
fileField: _pagebuilderThumbnail
|
||||
i18n:
|
||||
entry:
|
||||
languageField: lang
|
||||
groupField: translationKey
|
||||
sidebar:
|
||||
- group: publishing
|
||||
label: { de: "Veröffentlichung", en: "Publishing" }
|
||||
- group: settings
|
||||
label: { de: "Einstellungen", en: "Settings" }
|
||||
- group: seo
|
||||
label: { de: "SEO", en: "SEO" }
|
||||
|
||||
permissions:
|
||||
public:
|
||||
@@ -41,18 +45,22 @@ fields:
|
||||
type: boolean
|
||||
meta:
|
||||
label: { de: "Aktiv", en: "Active" }
|
||||
position: sidebar:publishing
|
||||
- name: type
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Typ", en: "Type" }
|
||||
position: sidebar:settings
|
||||
- name: lang
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Sprache", en: "Language" }
|
||||
position: sidebar:settings
|
||||
- name: translationKey
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Übersetzungsschlüssel", en: "Translation Key" }
|
||||
position: sidebar:settings
|
||||
- name: name
|
||||
type: string
|
||||
meta:
|
||||
@@ -68,104 +76,380 @@ fields:
|
||||
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: _pagebuilderThumbnail
|
||||
type: file
|
||||
meta:
|
||||
hide: true
|
||||
- name: blocks
|
||||
type: object[]
|
||||
meta:
|
||||
label: { de: "Inhaltsblöcke", en: "Content Blocks" }
|
||||
position: main
|
||||
drillDown: false
|
||||
widget: pagebuilder
|
||||
pagebuilder:
|
||||
blockTypeField: type
|
||||
defaultViewport: desktop
|
||||
blockRegistry:
|
||||
file: /_/assets/dist/admin.mjs
|
||||
subFields:
|
||||
- name: hide
|
||||
type: boolean
|
||||
meta:
|
||||
label: { de: "Block ausblenden", en: "Hide Block" }
|
||||
dependsOn:
|
||||
eval: $parent.type != ''
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-4
|
||||
- name: type
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Blocktyp", en: "Block Type" }
|
||||
widget: select
|
||||
defaultValue: richtext
|
||||
helperText:
|
||||
de: "Wähle zuerst den Blocktyp. Danach werden nur die passenden Felder angezeigt."
|
||||
en: "Choose the block type first. Then only the relevant fields are shown."
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-8
|
||||
choices:
|
||||
- id: hero
|
||||
name: { de: "Hero", en: "Hero" }
|
||||
description:
|
||||
{
|
||||
de: "Hero mit Bild und Call-to-Action.",
|
||||
en: "Hero with image and call to action.",
|
||||
}
|
||||
- id: features
|
||||
name: { de: "Features", en: "Features" }
|
||||
description:
|
||||
{
|
||||
de: "Feature-Übersicht mit strukturierten Boxen.",
|
||||
en: "Feature overview with structured boxes.",
|
||||
}
|
||||
- id: richtext
|
||||
name: { de: "Richtext", en: "Richtext" }
|
||||
description:
|
||||
{
|
||||
de: "Fließtext optional mit Bild.",
|
||||
en: "Body text optionally with image.",
|
||||
}
|
||||
- id: accordion
|
||||
name: { de: "Akkordeon", en: "Accordion" }
|
||||
description:
|
||||
{
|
||||
de: "Aufklappbare Fragen und Antworten.",
|
||||
en: "Expandable questions and answers.",
|
||||
}
|
||||
- id: contact-form
|
||||
name: { de: "Kontaktformular", en: "Contact Form" }
|
||||
description:
|
||||
{
|
||||
de: "Kontaktformular mit Intro-Text.",
|
||||
en: "Contact form with intro text.",
|
||||
}
|
||||
- name: headline
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Überschrift", en: "Headline" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'hero' || $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion' || $parent.type == 'contact-form'
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-8
|
||||
- name: headlineH1
|
||||
type: boolean
|
||||
meta:
|
||||
label: { de: "Als H1 rendern", en: "Render as H1" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'hero'
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-4
|
||||
- name: subline
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Unterzeile", en: "Subline" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'hero'
|
||||
inputProps:
|
||||
multiline: true
|
||||
rows: 3
|
||||
- name: tagline
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Dachzeile", en: "Tagline" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'hero' || $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion'
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-6
|
||||
- name: anchorId
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Anker-ID", en: "Anchor ID" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion' || $parent.type == 'contact-form'
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-6
|
||||
- name: containerWidth
|
||||
type: string
|
||||
- name: background
|
||||
type: object
|
||||
subFields:
|
||||
- name: color
|
||||
type: string
|
||||
- name: image
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Containerbreite", en: "Container Width" }
|
||||
widget: select
|
||||
dependsOn:
|
||||
eval: $parent.type == 'hero'
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-6
|
||||
choices:
|
||||
- id: ""
|
||||
name: { de: "Standard", en: "Default" }
|
||||
- id: wide
|
||||
name: { de: "Breit", en: "Wide" }
|
||||
- id: full
|
||||
name: { de: "Volle Breite", en: "Full Width" }
|
||||
- name: padding
|
||||
type: object
|
||||
meta:
|
||||
label: { de: "Innenabstand", en: "Padding" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion' || $parent.type == 'contact-form'
|
||||
drillDown: false
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-6
|
||||
subFields:
|
||||
- name: top
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Oben", en: "Top" }
|
||||
widget: select
|
||||
hideLabel: true
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-6
|
||||
choices:
|
||||
- id: ""
|
||||
name: { de: "Standard", en: "Default" }
|
||||
- id: sm
|
||||
name: { de: "Klein", en: "Small" }
|
||||
- id: md
|
||||
name: { de: "Mittel", en: "Medium" }
|
||||
- id: lg
|
||||
name: { de: "Groß", en: "Large" }
|
||||
- name: bottom
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Unten", en: "Bottom" }
|
||||
widget: select
|
||||
hideLabel: true
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-6
|
||||
choices:
|
||||
- id: ""
|
||||
name: { de: "Standard", en: "Default" }
|
||||
- id: sm
|
||||
name: { de: "Klein", en: "Small" }
|
||||
- id: md
|
||||
name: { de: "Mittel", en: "Medium" }
|
||||
- id: lg
|
||||
name: { de: "Groß", en: "Large" }
|
||||
- name: callToAction
|
||||
type: object
|
||||
meta:
|
||||
label: { de: "Call-to-Action", en: "Call to Action" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'hero'
|
||||
drillDown: false
|
||||
subFields:
|
||||
- name: buttonText
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Button-Text", en: "Button Text" }
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-4
|
||||
- name: buttonLink
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Button-Link", en: "Button Link" }
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-5
|
||||
- name: buttonTarget
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Link-Target", en: "Link Target" }
|
||||
widget: select
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-3
|
||||
choices:
|
||||
- id: ""
|
||||
name: { de: "Gleiches Fenster", en: "Same Window" }
|
||||
- id: _blank
|
||||
name: { de: "Neuer Tab", en: "New Tab" }
|
||||
- name: heroImage
|
||||
type: object
|
||||
meta:
|
||||
label: { de: "Hero-Bild", en: "Hero Image" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'hero'
|
||||
drillDown: false
|
||||
subFields:
|
||||
- name: image
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Bild", en: "Image" }
|
||||
widget: foreignMedia
|
||||
foreign:
|
||||
collection: medialib
|
||||
id: _id
|
||||
subNavigation: 0
|
||||
- name: text
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Text", en: "Text" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'richtext'
|
||||
widget: richtext
|
||||
inputProps:
|
||||
rows: 8
|
||||
- name: featureBoxes
|
||||
type: object[]
|
||||
meta:
|
||||
label: { de: "Feature-Boxen", en: "Feature Boxes" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'features'
|
||||
drillDown: false
|
||||
preview: title
|
||||
subFields:
|
||||
- name: icon
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Icon", en: "Icon" }
|
||||
widget: select
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-4
|
||||
choices:
|
||||
- id: lightning
|
||||
name: { de: "Blitz", en: "Lightning" }
|
||||
- id: palette
|
||||
name: { de: "Palette", en: "Palette" }
|
||||
- id: database
|
||||
name: { de: "Daten", en: "Data" }
|
||||
- id: globe
|
||||
name: { de: "Globus", en: "Globe" }
|
||||
- id: monitor
|
||||
name: { de: "Monitor", en: "Monitor" }
|
||||
- id: flask
|
||||
name: { de: "Labor", en: "Lab" }
|
||||
- name: title
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Titel", en: "Title" }
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-8
|
||||
- name: text
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Text", en: "Text" }
|
||||
inputProps:
|
||||
multiline: true
|
||||
rows: 4
|
||||
- name: imagePosition
|
||||
type: string
|
||||
- name: imageRounded
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Bildposition", en: "Image Position" }
|
||||
widget: select
|
||||
dependsOn:
|
||||
eval: $parent.type == 'richtext'
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-4
|
||||
choices:
|
||||
- id: none
|
||||
name: { de: "Kein Bild", en: "No Image" }
|
||||
- id: left
|
||||
name: { de: "Links", en: "Left" }
|
||||
- id: right
|
||||
name: { de: "Rechts", en: "Right" }
|
||||
- name: image
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Bild", en: "Image" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'richtext'
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-4
|
||||
widget: foreignMedia
|
||||
foreign:
|
||||
collection: medialib
|
||||
id: _id
|
||||
subNavigation: 0
|
||||
- name: accordionItems
|
||||
type: object[]
|
||||
meta:
|
||||
label: { de: "Akkordeon-Elemente", en: "Accordion Items" }
|
||||
dependsOn:
|
||||
eval: $parent.type == 'accordion'
|
||||
drillDown: true
|
||||
subFields:
|
||||
- name: question
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Frage", en: "Question" }
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-8
|
||||
- name: answer
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Antwort", en: "Answer" }
|
||||
widget: richtext
|
||||
inputProps:
|
||||
rows: 6
|
||||
containerProps:
|
||||
layout:
|
||||
breakAfter: true
|
||||
- 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
|
||||
meta:
|
||||
label: { de: "Initial geöffnet", en: "Initially Open" }
|
||||
containerProps:
|
||||
layout:
|
||||
size: col-4
|
||||
- name: meta
|
||||
type: object
|
||||
meta:
|
||||
widget: containerLessObject
|
||||
label: { de: "SEO", en: "SEO" }
|
||||
position: sidebar:seo
|
||||
subFields:
|
||||
- name: title
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Meta-Titel", en: "Meta Title" }
|
||||
- name: description
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Meta-Beschreibung", en: "Meta Description" }
|
||||
inputProps:
|
||||
multiline: true
|
||||
rows: 3
|
||||
- name: keywords
|
||||
type: string
|
||||
type: string[]
|
||||
meta:
|
||||
label: { de: "Meta-Schlüsselwörter", en: "Meta Keywords" }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
########################################################################
|
||||
# Media library — reusable uploaded assets referenced via foreignMedia
|
||||
########################################################################
|
||||
|
||||
name: medialib
|
||||
meta:
|
||||
label: { de: "Mediathek", en: "Media Library" }
|
||||
muiIcon: image_multiple
|
||||
group: media
|
||||
viewHint:
|
||||
media:
|
||||
ai:
|
||||
targetField: alt
|
||||
prompt: Beschreibe das Bild kurz und sachlich fuer einen barrierefreien Alt-Text.
|
||||
image:
|
||||
maxWidth: 1280
|
||||
maxHeight: 1280
|
||||
quality: 0.82
|
||||
upload:
|
||||
autoFill:
|
||||
file: file
|
||||
preview:
|
||||
label: title
|
||||
secondary: description
|
||||
tertiary: tags
|
||||
image: file
|
||||
table:
|
||||
- file
|
||||
- title
|
||||
- tags
|
||||
subNavigation:
|
||||
- name: images
|
||||
label: { de: "Bilder", en: "Images" }
|
||||
muiIcon: image
|
||||
filter:
|
||||
file.type_like: ^image/
|
||||
- name: documents
|
||||
label: { de: "Dokumente", en: "Documents" }
|
||||
muiIcon: file_document
|
||||
filter:
|
||||
file.type_like: ^(?!image/|video/|audio/).+
|
||||
|
||||
permissions:
|
||||
public:
|
||||
methods:
|
||||
get: true
|
||||
user:
|
||||
methods:
|
||||
get: true
|
||||
post: true
|
||||
put: true
|
||||
delete: true
|
||||
|
||||
imageFilter:
|
||||
xs-webp:
|
||||
- width: 100
|
||||
height: 100
|
||||
fit: true
|
||||
quality: 60
|
||||
outputType: webp
|
||||
s-webp:
|
||||
- width: 300
|
||||
quality: 70
|
||||
outputType: webp
|
||||
m-webp:
|
||||
- width: 600
|
||||
quality: 76
|
||||
outputType: webp
|
||||
l-webp:
|
||||
- width: 1200
|
||||
quality: 80
|
||||
outputType: webp
|
||||
xl-webp:
|
||||
- width: 2000
|
||||
quality: 84
|
||||
outputType: webp
|
||||
xxl-webp:
|
||||
- width: 2800
|
||||
quality: 88
|
||||
outputType: webp
|
||||
|
||||
fields:
|
||||
- name: file
|
||||
type: file
|
||||
meta:
|
||||
label: { de: "Datei", en: "File" }
|
||||
widget: file
|
||||
downscale:
|
||||
maxWidth: 2048
|
||||
maxHeight: 2048
|
||||
- name: title
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Titel", en: "Title" }
|
||||
- name: alt
|
||||
type: object
|
||||
meta:
|
||||
label: { de: "Alt-Text", en: "Alt Text" }
|
||||
subFields:
|
||||
- name: de
|
||||
type: string
|
||||
meta:
|
||||
label: Deutsch
|
||||
- name: en
|
||||
type: string
|
||||
meta:
|
||||
label: English
|
||||
- name: description
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Beschreibung", en: "Description" }
|
||||
widget: text
|
||||
inputProps:
|
||||
multiline: true
|
||||
rows: 4
|
||||
- name: tags
|
||||
type: string[]
|
||||
meta:
|
||||
label: { de: "Tags", en: "Tags" }
|
||||
widget: chipArray
|
||||
@@ -6,19 +6,45 @@ 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
|
||||
group: structure
|
||||
viewHint:
|
||||
navigation:
|
||||
nodesField: elements
|
||||
preview:
|
||||
label: name
|
||||
secondary:
|
||||
eval: "$this.external && $this.externalUrl ? $this.externalUrl : ($this._lookup?.page ? $this._lookup.page.name + ' (' + $this._lookup.page.path + ')' : '')"
|
||||
select: [external, externalUrl, page]
|
||||
declaredTrees:
|
||||
- label: { de: "Header DE", en: "Header DE" }
|
||||
singleton:
|
||||
type: header
|
||||
language: de
|
||||
maxLevel: 2
|
||||
- label: { de: "Header EN", en: "Header EN" }
|
||||
singleton:
|
||||
type: header
|
||||
language: en
|
||||
maxLevel: 2
|
||||
- label: { de: "Footer DE", en: "Footer DE" }
|
||||
singleton:
|
||||
type: footer
|
||||
language: de
|
||||
maxLevel: 1
|
||||
- label: { de: "Footer EN", en: "Footer EN" }
|
||||
singleton:
|
||||
type: footer
|
||||
language: en
|
||||
maxLevel: 1
|
||||
preview:
|
||||
label: type
|
||||
secondary: language
|
||||
table:
|
||||
- type
|
||||
- language
|
||||
sidebar:
|
||||
- group: settings
|
||||
label: { de: "Einstellungen", en: "Settings" }
|
||||
|
||||
permissions:
|
||||
public:
|
||||
@@ -36,15 +62,30 @@ fields:
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Sprache", en: "Language" }
|
||||
position: sidebar:settings
|
||||
widget: select
|
||||
choices:
|
||||
- id: de
|
||||
name: { de: "Deutsch", en: "German" }
|
||||
- id: en
|
||||
name: { de: "Englisch", en: "English" }
|
||||
- name: type
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Typ", en: "Type" }
|
||||
helperText: { de: "header oder footer", en: "header or footer" }
|
||||
position: sidebar:settings
|
||||
widget: select
|
||||
choices:
|
||||
- id: header
|
||||
name: { de: "Header", en: "Header" }
|
||||
- id: footer
|
||||
name: { de: "Footer", en: "Footer" }
|
||||
- name: elements
|
||||
type: object[]
|
||||
meta:
|
||||
label: { de: "Elemente", en: "Elements" }
|
||||
preview: name
|
||||
subFields:
|
||||
- name: name
|
||||
type: string
|
||||
@@ -53,7 +94,11 @@ fields:
|
||||
- name: page
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Seite (Content-ID)", en: "Page (Content ID)" }
|
||||
label: { de: "Seite", en: "Page" }
|
||||
widget: foreignKey
|
||||
foreign:
|
||||
collection: content
|
||||
id: id
|
||||
- name: external
|
||||
type: boolean
|
||||
meta:
|
||||
@@ -66,3 +111,7 @@ fields:
|
||||
type: string
|
||||
meta:
|
||||
label: { de: "Anker", en: "Anchor" }
|
||||
- name: elements
|
||||
type: object[]
|
||||
meta:
|
||||
label: { de: "Unterpunkte", en: "Child Items" }
|
||||
|
||||
+2
-16
@@ -6,22 +6,8 @@ name: ssr
|
||||
meta:
|
||||
label: { de: "SSR Dummy", en: "ssr dummy" }
|
||||
muiIcon: server
|
||||
rowIdentTpl: { twig: "{{ id }}" }
|
||||
|
||||
views:
|
||||
- type: simpleList
|
||||
mediaQuery: "(max-width: 600px)"
|
||||
primaryText: id
|
||||
secondaryText: insertTime
|
||||
tertiaryText: path
|
||||
- type: table
|
||||
columns:
|
||||
- id
|
||||
- insertTime
|
||||
- source: path
|
||||
filter: true
|
||||
- source: validUntil
|
||||
- dependencies
|
||||
group: system
|
||||
hide: true
|
||||
|
||||
permissions:
|
||||
public:
|
||||
|
||||
+23
-6
@@ -1,15 +1,32 @@
|
||||
namespace: __NAMESPACE__
|
||||
namespace: __TIBI_NAMESPACE__
|
||||
|
||||
meta:
|
||||
imageUrl:
|
||||
eval: "$projectBase + '_/assets/img/admin-pic.jpg'"
|
||||
injectIntoHead:
|
||||
# inject font faces (not possible in shadow dom for preview)
|
||||
eval: |
|
||||
"<link rel='stylesheet' href='" + $projectBase + "_/assets/fonts/fonts.css?t=" + $project?.updateTime + "'>"
|
||||
eval: "$projectBase + '_/assets/img/admin-pic.svg'"
|
||||
i18n:
|
||||
defaultLanguage: de
|
||||
languages:
|
||||
- code: de
|
||||
label: Deutsch
|
||||
- code: en
|
||||
label: English
|
||||
collectionGroups:
|
||||
- name: content
|
||||
label: { de: "Inhalte", en: "Content" }
|
||||
icon: article
|
||||
- name: media
|
||||
label: { de: "Medien", en: "Media" }
|
||||
icon: image_multiple
|
||||
- name: structure
|
||||
label: { de: "Struktur", en: "Structure" }
|
||||
icon: account_tree
|
||||
- name: system
|
||||
label: { de: "System", en: "System" }
|
||||
icon: settings
|
||||
|
||||
collections:
|
||||
- !include collections/content.yml
|
||||
- !include collections/medialib.yml
|
||||
- !include collections/navigation.yml
|
||||
- !include collections/ssr.yml
|
||||
|
||||
|
||||
+6
-3
@@ -96,13 +96,16 @@ const esbuildSvelte = sveltePlugin({
|
||||
const options = {
|
||||
logLevel: "info",
|
||||
color: true,
|
||||
entryPoints: ["./frontend/src/index.ts"],
|
||||
outfile: distDir + "/index.mjs",
|
||||
entryPoints: ["./frontend/src/index.ts", "./frontend/src/admin.ts"],
|
||||
outdir: distDir,
|
||||
entryNames: "[name]",
|
||||
chunkNames: "chunks/[name]-[hash]",
|
||||
outExtension: { ".js": ".mjs" },
|
||||
metafile: true,
|
||||
format: "esm",
|
||||
minify: process.argv[2] == "build",
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
splitting: true,
|
||||
define: {
|
||||
__MOCK__: process.env.MOCK === "1" ? "true" : "false",
|
||||
},
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ SetEnv MATOMO no
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
RewriteRule ^/?api/(.*)$ http://tibi-server:8080/api/v1/_/__NAMESPACE__/$1 [P,QSA,L]
|
||||
RewriteRule ^/?api/(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/$1 [P,QSA,L]
|
||||
|
||||
# Set the Host header for requests to sentry
|
||||
RequestHeader set Host sentry.basehosts.de env=proxy-sentry
|
||||
@@ -36,7 +36,7 @@ SetEnv MATOMO no
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^/?(.*)$ http://tibi-server:8080/api/v1/_/__NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr]
|
||||
RewriteRule ^/?(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr]
|
||||
# RewriteRule (.*) /spa.html [QSA,L]
|
||||
|
||||
</ifModule>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Tibi Starter admin preview</title>
|
||||
<desc id="desc">Abstract admin preview illustration for the Tibi starter project.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0f2d45" />
|
||||
<stop offset="50%" stop-color="#1f5f7a" />
|
||||
<stop offset="100%" stop-color="#d9e7f1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="card" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95" />
|
||||
<stop offset="100%" stop-color="#dfeaf2" stop-opacity="0.92" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#bg)" />
|
||||
<circle cx="1320" cy="140" r="160" fill="#ffffff" fill-opacity="0.14" />
|
||||
<circle cx="260" cy="760" r="220" fill="#ffffff" fill-opacity="0.09" />
|
||||
<rect x="180" y="150" width="1240" height="600" rx="36" fill="url(#card)" />
|
||||
<rect x="240" y="210" width="280" height="480" rx="24" fill="#10324a" fill-opacity="0.92" />
|
||||
<rect x="570" y="230" width="780" height="74" rx="18" fill="#1f5f7a" fill-opacity="0.18" />
|
||||
<rect x="570" y="340" width="360" height="250" rx="24" fill="#1f5f7a" fill-opacity="0.16" />
|
||||
<rect x="970" y="340" width="380" height="110" rx="24" fill="#1f5f7a" fill-opacity="0.13" />
|
||||
<rect x="970" y="480" width="380" height="110" rx="24" fill="#1f5f7a" fill-opacity="0.13" />
|
||||
<path d="M300 300h160M300 360h160M300 420h160M300 480h110" stroke="#e7f0f6" stroke-width="22" stroke-linecap="round" />
|
||||
<path d="M635 470l92-92 78 78 142-142 180 180" fill="none" stroke="#0f2d45" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="748" cy="330" r="34" fill="#0f2d45" />
|
||||
<text x="570" y="275" fill="#10324a" font-family="Georgia, serif" font-size="42" font-weight="700">Tibi Admin Nova Starter</text>
|
||||
<text x="570" y="655" fill="#10324a" font-family="Arial, sans-serif" font-size="34">Lean starter base with content, media, i18n, and SSR.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Placeholder image</title>
|
||||
<desc id="desc">Neutral placeholder graphic for missing media assets.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#e5eef6" />
|
||||
<stop offset="100%" stop-color="#cfdcea" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="900" fill="url(#bg)" />
|
||||
<g fill="none" stroke="#7c97b2" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="120" y="120" width="960" height="660" rx="36" />
|
||||
<path d="M220 650l190-190 140 140 170-210 260 260" />
|
||||
<circle cx="410" cy="310" r="70" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 752 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#10324a" />
|
||||
<path d="M18 20h28v8H36v20h-8V28H18z" fill="#ffffff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 183 B |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Tibi Svelte Starter",
|
||||
"short_name": "Tibi Starter",
|
||||
"icons": [],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
+129
-33
@@ -1,7 +1,8 @@
|
||||
[
|
||||
{
|
||||
"id": "home-de",
|
||||
"_id": "home-de",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000001"
|
||||
},
|
||||
"active": true,
|
||||
"type": "page",
|
||||
"lang": "de",
|
||||
@@ -21,19 +22,50 @@
|
||||
"buttonTarget": ""
|
||||
},
|
||||
"heroImage": {
|
||||
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
|
||||
"image": "6821c0a10000000000000201"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "features",
|
||||
"headline": "Was dieses Template kann",
|
||||
"tagline": "Features",
|
||||
"tagline": "Highlights",
|
||||
"anchorId": "features",
|
||||
"padding": {
|
||||
"top": "lg",
|
||||
"bottom": "lg"
|
||||
},
|
||||
"text": "<div class='grid gap-8 sm:grid-cols-2 lg:grid-cols-3'><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M13 2L3 14h9l-1 8 10-12h-9l1-8z\"/></svg></div><h3>Svelte 5 Runes</h3><p>Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.01 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.49-9-10-9z\"/><circle cx=\"7.5\" cy=\"11.5\" r=\"1.5\"/><circle cx=\"10.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"14.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"17.5\" cy=\"11.5\" r=\"1.5\"/></svg></div><h3>Tailwind CSS 4</h3><p>Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 22v-5\"/><path d=\"M9 8V2\"/><path d=\"M15 8V2\"/><path d=\"M18 8v5a6 6 0 0 1-12 0V8h12z\"/></svg></div><h3>Tibi CMS API</h3><p>Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M2 12h20\"/><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"/></svg></div><h3>i18n Built-in</h3><p>Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M8 21h8\"/><path d=\"M12 17v4\"/></svg></div><h3>SSR via goja</h3><p>Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 2h6\"/><path d=\"M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.578A1 1 0 0 0 5.598 22h12.804a1 1 0 0 0 .878-1.422l-5.069-10.155A2 2 0 0 1 14 9.527V2\"/></svg></div><h3>Playwright Tests</h3><p>E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go.</p></div></div>"
|
||||
"featureBoxes": [
|
||||
{
|
||||
"icon": "lightning",
|
||||
"title": "Svelte 5 Runes",
|
||||
"text": "Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance."
|
||||
},
|
||||
{
|
||||
"icon": "palette",
|
||||
"title": "Tailwind CSS 4",
|
||||
"text": "Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds."
|
||||
},
|
||||
{
|
||||
"icon": "database",
|
||||
"title": "Tibi CMS API",
|
||||
"text": "Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung."
|
||||
},
|
||||
{
|
||||
"icon": "globe",
|
||||
"title": "Mehrsprachig",
|
||||
"text": "Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel."
|
||||
},
|
||||
{
|
||||
"icon": "monitor",
|
||||
"title": "SSR via goja",
|
||||
"text": "Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung."
|
||||
},
|
||||
{
|
||||
"icon": "flask",
|
||||
"title": "Playwright Tests",
|
||||
"text": "E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "richtext",
|
||||
@@ -44,9 +76,9 @@
|
||||
"top": "lg",
|
||||
"bottom": "sm"
|
||||
},
|
||||
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
|
||||
"image": "6821c0a10000000000000202",
|
||||
"imagePosition": "right",
|
||||
"text": "<p>Starte die Entwicklungsumgebung mit <code>make docker-up && make docker-start</code>. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.</p><p>Für Offline-Entwicklung aktiviere den Mock-Modus mit <code>MOCK=1</code> in der <code>.env</code>. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.</p><p>Jeder Block-Typ (Hero, Richtext, Accordion, Features) ist eine eigene Svelte-Komponente — <strong>erweiterbar und austauschbar</strong>.</p>"
|
||||
"text": "<p>Starte die Entwicklungsumgebung mit <code>make docker-up && make docker-start</code>. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.</p><p>Für Offline-Entwicklung aktiviere den Mock-Modus mit <code>MOCK=1</code> in der <code>.env</code>. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.</p><p>Jeder Block-Typ (Hero, Richtext, Accordion, Kontaktformular) ist eine eigene Svelte-Komponente — <strong>erweiterbar und austauschbar</strong>.</p>"
|
||||
},
|
||||
{
|
||||
"type": "accordion",
|
||||
@@ -81,12 +113,19 @@
|
||||
"meta": {
|
||||
"title": "Tibi Svelte Starter — Modernes CMS-Template",
|
||||
"description": "Svelte 5, Tailwind CSS 4, SSR, i18n und Playwright-Tests — das perfekte Starterkit.",
|
||||
"keywords": "svelte, tibi, cms, starter, template"
|
||||
"keywords": [
|
||||
"svelte",
|
||||
"tibi",
|
||||
"cms",
|
||||
"starter",
|
||||
"template"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "home-en",
|
||||
"_id": "home-en",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000002"
|
||||
},
|
||||
"active": true,
|
||||
"type": "page",
|
||||
"lang": "en",
|
||||
@@ -106,7 +145,7 @@
|
||||
"buttonTarget": ""
|
||||
},
|
||||
"heroImage": {
|
||||
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
|
||||
"image": "6821c0a10000000000000201"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -118,7 +157,38 @@
|
||||
"top": "lg",
|
||||
"bottom": "lg"
|
||||
},
|
||||
"text": "<div class='grid gap-8 sm:grid-cols-2 lg:grid-cols-3'><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M13 2L3 14h9l-1 8 10-12h-9l1-8z\"/></svg></div><h3>Svelte 5 Runes</h3><p>Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.01 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.49-9-10-9z\"/><circle cx=\"7.5\" cy=\"11.5\" r=\"1.5\"/><circle cx=\"10.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"14.5\" cy=\"7.5\" r=\"1.5\"/><circle cx=\"17.5\" cy=\"11.5\" r=\"1.5\"/></svg></div><h3>Tailwind CSS 4</h3><p>Utility-first styling with custom theme, dark-mode-ready and blazing fast builds.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 22v-5\"/><path d=\"M9 8V2\"/><path d=\"M15 8V2\"/><path d=\"M18 8v5a6 6 0 0 1-12 0V8h12z\"/></svg></div><h3>Tibi CMS API</h3><p>Collections, hooks, media library — all via REST API. With mock mode for offline development.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M2 12h20\"/><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"/></svg></div><h3>Built-in i18n</h3><p>Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M8 21h8\"/><path d=\"M12 17v4\"/></svg></div><h3>SSR via goja</h3><p>Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation.</p></div><div class='feature-card'><div class='feature-icon'><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 2h6\"/><path d=\"M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.578A1 1 0 0 0 5.598 22h12.804a1 1 0 0 0 .878-1.422l-5.069-10.155A2 2 0 0 1 14 9.527V2\"/></svg></div><h3>Playwright Tests</h3><p>E2E, API, visual regression and video tours — all preconfigured and ready to go.</p></div></div>"
|
||||
"featureBoxes": [
|
||||
{
|
||||
"icon": "lightning",
|
||||
"title": "Svelte 5 Runes",
|
||||
"text": "Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance."
|
||||
},
|
||||
{
|
||||
"icon": "palette",
|
||||
"title": "Tailwind CSS 4",
|
||||
"text": "Utility-first styling with custom theme, dark-mode-ready and blazing fast builds."
|
||||
},
|
||||
{
|
||||
"icon": "database",
|
||||
"title": "Tibi CMS API",
|
||||
"text": "Collections, hooks, media library — all via REST API. With mock mode for offline development."
|
||||
},
|
||||
{
|
||||
"icon": "globe",
|
||||
"title": "Built-in i18n",
|
||||
"text": "Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible."
|
||||
},
|
||||
{
|
||||
"icon": "monitor",
|
||||
"title": "SSR via goja",
|
||||
"text": "Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation."
|
||||
},
|
||||
{
|
||||
"icon": "flask",
|
||||
"title": "Playwright Tests",
|
||||
"text": "E2E, API, visual regression and video tours — all preconfigured and ready to go."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "richtext",
|
||||
@@ -129,9 +199,9 @@
|
||||
"top": "lg",
|
||||
"bottom": "sm"
|
||||
},
|
||||
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
|
||||
"image": "6821c0a10000000000000202",
|
||||
"imagePosition": "right",
|
||||
"text": "<p>Start the dev environment with <code>make docker-up && make docker-start</code>. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.</p><p>For offline development, enable mock mode with <code>MOCK=1</code> in <code>.env</code>. Content is loaded via the Tibi API and rendered with the BlockRenderer.</p><p>Each block type (Hero, Richtext, Accordion, Features) is its own Svelte component — <strong>extensible and swappable</strong>.</p>"
|
||||
"text": "<p>Start the dev environment with <code>make docker-up && make docker-start</code>. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.</p><p>For offline development, enable mock mode with <code>MOCK=1</code> in <code>.env</code>. Content is loaded via the Tibi API and rendered with the BlockRenderer.</p><p>Each block type (Hero, Richtext, Accordion, Contact Form) is its own Svelte component — <strong>extensible and swappable</strong>.</p>"
|
||||
},
|
||||
{
|
||||
"type": "accordion",
|
||||
@@ -166,12 +236,19 @@
|
||||
"meta": {
|
||||
"title": "Tibi Svelte Starter — Modern CMS Template",
|
||||
"description": "Svelte 5, Tailwind CSS 4, SSR, i18n and Playwright tests — the perfect starter kit.",
|
||||
"keywords": "svelte, tibi, cms, starter, template"
|
||||
"keywords": [
|
||||
"svelte",
|
||||
"tibi",
|
||||
"cms",
|
||||
"starter",
|
||||
"template"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "about-de",
|
||||
"_id": "about-de",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000003"
|
||||
},
|
||||
"active": true,
|
||||
"type": "page",
|
||||
"lang": "de",
|
||||
@@ -186,7 +263,7 @@
|
||||
"subline": "Gebaut für Teams, die schnell professionelle Webprojekte umsetzen wollen.",
|
||||
"containerWidth": "full",
|
||||
"heroImage": {
|
||||
"externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80"
|
||||
"image": "6821c0a10000000000000203"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -205,7 +282,7 @@
|
||||
"top": "md",
|
||||
"bottom": "lg"
|
||||
},
|
||||
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
|
||||
"image": "6821c0a10000000000000204",
|
||||
"imagePosition": "left",
|
||||
"text": "<p>Jede Komponente wurde sorgfältig ausgewählt:</p><ul><li><strong>Svelte 5</strong> — Reaktives Framework mit Runes-API</li><li><strong>Tailwind CSS 4</strong> — Utility-first CSS mit @theme</li><li><strong>esbuild</strong> — Extrem schneller Bundler</li><li><strong>Tibi CMS</strong> — Headless CMS mit Go-Backend</li><li><strong>goja SSR</strong> — Server-Side Rendering in Go</li><li><strong>Playwright</strong> — Modernes Testing-Framework</li></ul>"
|
||||
}
|
||||
@@ -213,12 +290,18 @@
|
||||
"meta": {
|
||||
"title": "Über das Template — Tibi Svelte Starter",
|
||||
"description": "Architektur und Tech-Stack des Tibi Svelte Starter Templates.",
|
||||
"keywords": "svelte, über uns, template, architektur"
|
||||
"keywords": [
|
||||
"svelte",
|
||||
"über uns",
|
||||
"template",
|
||||
"architektur"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "about-en",
|
||||
"_id": "about-en",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000004"
|
||||
},
|
||||
"active": true,
|
||||
"type": "page",
|
||||
"lang": "en",
|
||||
@@ -233,7 +316,7 @@
|
||||
"subline": "Built for teams who want to ship professional web projects fast.",
|
||||
"containerWidth": "full",
|
||||
"heroImage": {
|
||||
"externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80"
|
||||
"image": "6821c0a10000000000000203"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -252,7 +335,7 @@
|
||||
"top": "md",
|
||||
"bottom": "lg"
|
||||
},
|
||||
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
|
||||
"image": "6821c0a10000000000000204",
|
||||
"imagePosition": "left",
|
||||
"text": "<p>Every component was carefully chosen:</p><ul><li><strong>Svelte 5</strong> — Reactive framework with Runes API</li><li><strong>Tailwind CSS 4</strong> — Utility-first CSS with @theme</li><li><strong>esbuild</strong> — Extremely fast bundler</li><li><strong>Tibi CMS</strong> — Headless CMS with Go backend</li><li><strong>goja SSR</strong> — Server-side rendering in Go</li><li><strong>Playwright</strong> — Modern testing framework</li></ul>"
|
||||
}
|
||||
@@ -260,12 +343,18 @@
|
||||
"meta": {
|
||||
"title": "About the Template — Tibi Svelte Starter",
|
||||
"description": "Architecture and tech stack of the Tibi Svelte Starter Template.",
|
||||
"keywords": "svelte, about, template, architecture"
|
||||
"keywords": [
|
||||
"svelte",
|
||||
"about",
|
||||
"template",
|
||||
"architecture"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contact-de",
|
||||
"_id": "contact-de",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000005"
|
||||
},
|
||||
"active": true,
|
||||
"type": "page",
|
||||
"lang": "de",
|
||||
@@ -280,7 +369,7 @@
|
||||
"subline": "Fragen, Feedback oder Projektanfragen? Schreib uns!",
|
||||
"containerWidth": "full",
|
||||
"heroImage": {
|
||||
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
|
||||
"image": "6821c0a10000000000000205"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -295,12 +384,16 @@
|
||||
"meta": {
|
||||
"title": "Kontakt — Tibi Svelte Starter",
|
||||
"description": "Nimm Kontakt mit uns auf.",
|
||||
"keywords": "kontakt, anfrage"
|
||||
"keywords": [
|
||||
"kontakt",
|
||||
"anfrage"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contact-en",
|
||||
"_id": "contact-en",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000006"
|
||||
},
|
||||
"active": true,
|
||||
"type": "page",
|
||||
"lang": "en",
|
||||
@@ -315,7 +408,7 @@
|
||||
"subline": "Questions, feedback or project inquiries? Get in touch!",
|
||||
"containerWidth": "full",
|
||||
"heroImage": {
|
||||
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
|
||||
"image": "6821c0a10000000000000205"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -330,7 +423,10 @@
|
||||
"meta": {
|
||||
"title": "Contact — Tibi Svelte Starter",
|
||||
"description": "Get in touch with us.",
|
||||
"keywords": "contact, inquiry"
|
||||
"keywords": [
|
||||
"contact",
|
||||
"inquiry"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
[
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000201"
|
||||
},
|
||||
"file": {
|
||||
"src": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80&auto=format&fit=crop",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
"title": "Homepage Hero",
|
||||
"alt": {
|
||||
"de": "Abstrakte Ansicht einer vernetzten digitalen Weltkarte",
|
||||
"en": "Abstract view of a connected digital world map"
|
||||
},
|
||||
"description": "Hero image for the homepage demo.",
|
||||
"tags": [
|
||||
"hero",
|
||||
"homepage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000202"
|
||||
},
|
||||
"file": {
|
||||
"src": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&q=80&auto=format&fit=crop",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
"title": "Workflow",
|
||||
"alt": {
|
||||
"de": "Laptop mit geoeffnetem Code-Editor auf einem Schreibtisch",
|
||||
"en": "Laptop with an open code editor on a desk"
|
||||
},
|
||||
"description": "Workflow image for the richtext demo block.",
|
||||
"tags": [
|
||||
"workflow",
|
||||
"development"
|
||||
]
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000203"
|
||||
},
|
||||
"file": {
|
||||
"src": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80&auto=format&fit=crop",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
"title": "About Team",
|
||||
"alt": {
|
||||
"de": "Team sitzt gemeinsam an einem grossen Tisch und arbeitet zusammen",
|
||||
"en": "Team working together around a large table"
|
||||
},
|
||||
"description": "Hero image for the about page demo.",
|
||||
"tags": [
|
||||
"about",
|
||||
"team"
|
||||
]
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000204"
|
||||
},
|
||||
"file": {
|
||||
"src": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&q=80&auto=format&fit=crop",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
"title": "Technology Stack",
|
||||
"alt": {
|
||||
"de": "Nahaufnahme eines Bildschirms mit Code und farbiger Syntaxhervorhebung",
|
||||
"en": "Close-up of a screen with code and colorful syntax highlighting"
|
||||
},
|
||||
"description": "Technology stack image for the about page demo.",
|
||||
"tags": [
|
||||
"about",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000205"
|
||||
},
|
||||
"file": {
|
||||
"src": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80&auto=format&fit=crop",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
"title": "Contact Hero",
|
||||
"alt": {
|
||||
"de": "Arbeitsplatz mit Laptop, Telefon und Notizbuch fuer Kontaktanfragen",
|
||||
"en": "Workspace with laptop, phone, and notebook for contact inquiries"
|
||||
},
|
||||
"description": "Hero image for the contact page demo.",
|
||||
"tags": [
|
||||
"contact",
|
||||
"hero"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,48 +1,96 @@
|
||||
[
|
||||
{
|
||||
"id": "header-de",
|
||||
"_id": "header-de",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000101"
|
||||
},
|
||||
"language": "de",
|
||||
"type": "header",
|
||||
"elements": [
|
||||
{ "name": "Startseite", "page": "/" },
|
||||
{ "name": "Über uns", "page": "/ueber-uns" },
|
||||
{ "name": "Kontakt", "page": "/kontakt" }
|
||||
{
|
||||
"name": "Startseite",
|
||||
"page": "6821c0a10000000000000001"
|
||||
},
|
||||
{
|
||||
"name": "Über uns",
|
||||
"page": "6821c0a10000000000000003"
|
||||
},
|
||||
{
|
||||
"name": "Kontakt",
|
||||
"page": "6821c0a10000000000000005"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "header-en",
|
||||
"_id": "header-en",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000102"
|
||||
},
|
||||
"language": "en",
|
||||
"type": "header",
|
||||
"elements": [
|
||||
{ "name": "Home", "page": "/" },
|
||||
{ "name": "About", "page": "/about" },
|
||||
{ "name": "Contact", "page": "/contact" }
|
||||
{
|
||||
"name": "Home",
|
||||
"page": "6821c0a10000000000000002"
|
||||
},
|
||||
{
|
||||
"name": "About",
|
||||
"page": "6821c0a10000000000000004"
|
||||
},
|
||||
{
|
||||
"name": "Contact",
|
||||
"page": "6821c0a10000000000000006"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "footer-de",
|
||||
"_id": "footer-de",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000103"
|
||||
},
|
||||
"language": "de",
|
||||
"type": "footer",
|
||||
"elements": [
|
||||
{ "name": "Startseite", "page": "/" },
|
||||
{ "name": "Über uns", "page": "/ueber-uns" },
|
||||
{ "name": "Kontakt", "page": "/kontakt" },
|
||||
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
|
||||
{
|
||||
"name": "Startseite",
|
||||
"page": "6821c0a10000000000000001"
|
||||
},
|
||||
{
|
||||
"name": "Über uns",
|
||||
"page": "6821c0a10000000000000003"
|
||||
},
|
||||
{
|
||||
"name": "Kontakt",
|
||||
"page": "6821c0a10000000000000005"
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"external": true,
|
||||
"externalUrl": "https://github.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "footer-en",
|
||||
"_id": "footer-en",
|
||||
"_id": {
|
||||
"$oid": "6821c0a10000000000000104"
|
||||
},
|
||||
"language": "en",
|
||||
"type": "footer",
|
||||
"elements": [
|
||||
{ "name": "Home", "page": "/" },
|
||||
{ "name": "About", "page": "/about" },
|
||||
{ "name": "Contact", "page": "/contact" },
|
||||
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
|
||||
{
|
||||
"name": "Home",
|
||||
"page": "6821c0a10000000000000002"
|
||||
},
|
||||
{
|
||||
"name": "About",
|
||||
"page": "6821c0a10000000000000004"
|
||||
},
|
||||
{
|
||||
"name": "Contact",
|
||||
"page": "6821c0a10000000000000006"
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"external": true,
|
||||
"externalUrl": "https://github.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
+102
-11
@@ -22,6 +22,9 @@
|
||||
stripLanguageFromPath,
|
||||
} from "./lib/i18n"
|
||||
|
||||
const CONTENT_MEDIA_LOOKUP = ["blocks.heroImage.image:medialib", "blocks.image:medialib"].join(",")
|
||||
const NAVIGATION_CONTENT_LOOKUP = "elements.page:content"
|
||||
|
||||
let { url = "" }: { url?: string } = $props()
|
||||
|
||||
initScrollRestoration()
|
||||
@@ -50,6 +53,48 @@
|
||||
}
|
||||
})
|
||||
|
||||
function focusMainContent(updateHash = false) {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
const mainContent = document.getElementById("main-content")
|
||||
if (!(mainContent instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (updateHash) {
|
||||
window.history.pushState(window.history.state, "", "#main-content")
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
mainContent.scrollIntoView({ block: "start" })
|
||||
mainContent.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleSkipToMainContent() {
|
||||
focusMainContent(true)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
const handleHashChange = () => {
|
||||
if (window.location.hash === "#main-content") {
|
||||
focusMainContent()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", handleHashChange)
|
||||
}
|
||||
})
|
||||
|
||||
// metrics
|
||||
let oldPath = $state("")
|
||||
$effect(() => {
|
||||
@@ -74,6 +119,16 @@
|
||||
let contentEntry = $state<ContentEntry | null>(null)
|
||||
let headerNav = $state<NavigationEntry | null>(null)
|
||||
let footerNav = $state<NavigationEntry | null>(null)
|
||||
|
||||
function resolveNavigationHref(item: NavigationElement): string {
|
||||
const resolvedPagePath = item._lookup?.page?.path || (item.page?.startsWith("/") ? item.page : "/")
|
||||
const localized = localizedPath(resolvedPagePath || "/")
|
||||
|
||||
if (!item.hash) return localized
|
||||
|
||||
const normalizedHash = item.hash.startsWith("#") ? item.hash : `#${item.hash}`
|
||||
return `${localized}${normalizedHash}`
|
||||
}
|
||||
let loading = $state(true)
|
||||
let notFound = $state(false)
|
||||
|
||||
@@ -103,18 +158,42 @@
|
||||
try {
|
||||
// Load navigation
|
||||
const [headerEntries, footerEntries] = await Promise.all([
|
||||
getCachedEntries<"navigation">("navigation", { type: "header", language: lang }),
|
||||
getCachedEntries<"navigation">("navigation", { type: "footer", language: lang }),
|
||||
getCachedEntries<"navigation">(
|
||||
"navigation",
|
||||
{ type: "header", language: lang },
|
||||
"sort",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ lookup: NAVIGATION_CONTENT_LOOKUP }
|
||||
),
|
||||
getCachedEntries<"navigation">(
|
||||
"navigation",
|
||||
{ type: "footer", language: lang },
|
||||
"sort",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ lookup: NAVIGATION_CONTENT_LOOKUP }
|
||||
),
|
||||
])
|
||||
headerNav = headerEntries[0] || null
|
||||
footerNav = footerEntries[0] || null
|
||||
|
||||
// Load content for current path
|
||||
const contentEntries = await getCachedEntries<"content">("content", {
|
||||
lang,
|
||||
path: routePath,
|
||||
active: true,
|
||||
})
|
||||
const contentEntries = await getCachedEntries<"content">(
|
||||
"content",
|
||||
{
|
||||
lang,
|
||||
path: routePath,
|
||||
active: true,
|
||||
},
|
||||
"sort",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ lookup: CONTENT_MEDIA_LOOKUP }
|
||||
)
|
||||
|
||||
if (contentEntries.length > 0) {
|
||||
contentEntry = contentEntries[0]
|
||||
@@ -157,6 +236,9 @@
|
||||
{#if contentEntry?.meta?.description}
|
||||
<meta name="description" content={contentEntry.meta.description} />
|
||||
{/if}
|
||||
{#if contentEntry?.meta?.keywords?.length}
|
||||
<meta name="keywords" content={contentEntry.meta.keywords.join(", ")} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<LoadingBar />
|
||||
@@ -168,6 +250,15 @@
|
||||
? 'bg-white/80 backdrop-blur-lg shadow-sm'
|
||||
: 'bg-transparent'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="main-content"
|
||||
onclick={handleSkipToMainContent}
|
||||
class="sr-only absolute left-4 top-4 z-50 rounded bg-brand-600 px-4 py-2 text-sm font-semibold text-white focus:not-sr-only"
|
||||
>
|
||||
{$_("skipToMainContent")}
|
||||
</button>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<a
|
||||
@@ -184,7 +275,7 @@
|
||||
{#if headerNav?.elements}
|
||||
{#each headerNav.elements as item}
|
||||
<a
|
||||
href={localizedPath(item.page || "/")}
|
||||
href={resolveNavigationHref(item)}
|
||||
class="text-sm font-medium transition-colors duration-300 hover:text-brand-500 {scrolled
|
||||
? 'text-gray-700'
|
||||
: 'text-white/90'}"
|
||||
@@ -248,7 +339,7 @@
|
||||
{#if headerNav?.elements}
|
||||
{#each headerNav.elements as item}
|
||||
<a
|
||||
href={localizedPath(item.page || "/")}
|
||||
href={resolveNavigationHref(item)}
|
||||
class="block text-gray-700 text-lg font-medium hover:text-brand-600 py-2"
|
||||
>
|
||||
{item.name}
|
||||
@@ -273,7 +364,7 @@
|
||||
</header>
|
||||
|
||||
<!-- ── Main Content ──────────────────────────────────────────── -->
|
||||
<main>
|
||||
<main id="main-content" tabindex="-1">
|
||||
{#if loading}
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="w-8 h-8 border-4 border-brand-200 border-t-brand-600 rounded-full animate-spin"></div>
|
||||
@@ -319,7 +410,7 @@
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={localizedPath(item.page || "/")}
|
||||
href={resolveNavigationHref(item)}
|
||||
class="text-sm hover:text-white transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
|
||||
+96
-2
@@ -1,4 +1,40 @@
|
||||
import type { SvelteComponent } from "svelte"
|
||||
import { mount, unmount, type Component, type SvelteComponent } from "svelte"
|
||||
import BlockRenderer from "./blocks/BlockRenderer.svelte"
|
||||
|
||||
const previewCssUrl = new URL("./index.css", import.meta.url).toString()
|
||||
|
||||
type BlockRenderContext = {
|
||||
namespace?: string
|
||||
apiBase?: string
|
||||
projectBase?: string
|
||||
}
|
||||
|
||||
type BlockHandle = {
|
||||
update(row: Record<string, any>, context?: BlockRenderContext): void
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
type BlockDefinition = {
|
||||
render(container: HTMLElement | ShadowRoot, row: Record<string, any>, context?: BlockRenderContext): BlockHandle
|
||||
css?: string[]
|
||||
previewStyles?: Record<string, string>
|
||||
label?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
type BlockPresentation = {
|
||||
label: string
|
||||
icon: string
|
||||
color: string
|
||||
}
|
||||
|
||||
function getAdminPreviewProps(props?: { [key: string]: any }) {
|
||||
return {
|
||||
isAdminPreview: true,
|
||||
...(props || {}),
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderedElement(
|
||||
component: typeof SvelteComponent,
|
||||
@@ -33,13 +69,71 @@ function getRenderedElement(
|
||||
|
||||
new component({
|
||||
target: target,
|
||||
props: options?.props,
|
||||
props: getAdminPreviewProps(options?.props),
|
||||
})
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
function createContentBlockDefinition(presentation: BlockPresentation): BlockDefinition {
|
||||
return {
|
||||
css: [previewCssUrl],
|
||||
label: presentation.label,
|
||||
icon: presentation.icon,
|
||||
color: presentation.color,
|
||||
previewStyles: {
|
||||
"background-color": "white",
|
||||
position: "relative",
|
||||
},
|
||||
render(container, row, context) {
|
||||
const target = document.createElement("div")
|
||||
target.dataset.adminPreview = "true"
|
||||
container.appendChild(target)
|
||||
|
||||
let mountedComponent = mount(BlockRenderer as Component<any>, {
|
||||
target,
|
||||
props: {
|
||||
blocks: [row as ContentBlockEntry],
|
||||
isAdminPreview: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
update(nextRow) {
|
||||
unmount(mountedComponent)
|
||||
target.innerHTML = ""
|
||||
mountedComponent = mount(BlockRenderer as Component<any>, {
|
||||
target,
|
||||
props: {
|
||||
blocks: [nextRow as ContentBlockEntry],
|
||||
isAdminPreview: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
destroy() {
|
||||
unmount(mountedComponent)
|
||||
target.remove()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const blockRegistry = {
|
||||
hero: createContentBlockDefinition({ label: "Hero", icon: "image", color: "#1d4ed8" }),
|
||||
features: createContentBlockDefinition({ label: "Features", icon: "view_quilt", color: "#0f766e" }),
|
||||
richtext: createContentBlockDefinition({ label: "Richtext", icon: "article", color: "#7c3aed" }),
|
||||
accordion: createContentBlockDefinition({ label: "Accordion", icon: "expand", color: "#b45309" }),
|
||||
"contact-form": createContentBlockDefinition({
|
||||
label: "Contact Form",
|
||||
icon: "mail",
|
||||
color: "#be185d",
|
||||
}),
|
||||
}
|
||||
|
||||
export {
|
||||
getAdminPreviewProps,
|
||||
getRenderedElement,
|
||||
blockRegistry,
|
||||
// pass also required svelte components here
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
let { icon = "lightning", className = "" }: { icon?: ContentFeatureBoxEntry["icon"]; className?: string } = $props()
|
||||
</script>
|
||||
|
||||
{#if icon === "palette"}
|
||||
<svg
|
||||
class={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.01 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.49-9-10-9z"
|
||||
></path>
|
||||
<circle cx="7.5" cy="11.5" r="1.5"></circle>
|
||||
<circle cx="10.5" cy="7.5" r="1.5"></circle>
|
||||
<circle cx="14.5" cy="7.5" r="1.5"></circle>
|
||||
<circle cx="17.5" cy="11.5" r="1.5"></circle>
|
||||
</svg>
|
||||
{:else if icon === "database"}
|
||||
<svg
|
||||
class={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 22v-5"></path>
|
||||
<path d="M9 8V2"></path>
|
||||
<path d="M15 8V2"></path>
|
||||
<path d="M18 8v5a6 6 0 0 1-12 0V8h12z"></path>
|
||||
</svg>
|
||||
{:else if icon === "globe"}
|
||||
<svg
|
||||
class={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M2 12h20"></path>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</svg>
|
||||
{:else if icon === "monitor"}
|
||||
<svg
|
||||
class={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"></rect>
|
||||
<path d="M8 21h8"></path>
|
||||
<path d="M12 17v4"></path>
|
||||
</svg>
|
||||
{:else if icon === "flask"}
|
||||
<svg
|
||||
class={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 2h6"></path>
|
||||
<path
|
||||
d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.578A1 1 0 0 0 5.598 22h12.804a1 1 0 0 0 .878-1.422l-5.069-10.155A2 2 0 0 1 14 9.527V2"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
import FeatureIcon from "./FeatureIcon.svelte"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
@@ -41,10 +42,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if block.text}
|
||||
<div class="prose max-w-none" use:reveal={{ delay: 200 }}>
|
||||
{@html block.text}
|
||||
{#if block.featureBoxes?.length}
|
||||
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3" use:reveal={{ delay: 200 }}>
|
||||
{#each block.featureBoxes as item}
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<FeatureIcon icon={item.icon} className="w-10 h-10" />
|
||||
</div>
|
||||
{#if item.title}
|
||||
<h3>{item.title}</h3>
|
||||
{/if}
|
||||
{#if item.text}
|
||||
<p>{item.text}</p>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,10 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
import { spaLink } from "../lib/navigation"
|
||||
import MedialibImage from "../widgets/MedialibImage.svelte"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
const hasImage = $derived(block.heroImage?.externalUrl || block.heroImage?.image)
|
||||
const resolvedHeroImage = $derived(
|
||||
block.heroImage?._lookup?.image ||
|
||||
(block._lookup?.["heroImage.image"] as MedialibEntry | null | undefined) ||
|
||||
null
|
||||
)
|
||||
const hasImage = $derived(!!resolvedHeroImage?.file?.src)
|
||||
const isAnchorLink = $derived(block.callToAction?.buttonLink?.startsWith("#"))
|
||||
</script>
|
||||
|
||||
@@ -17,9 +23,12 @@
|
||||
<!-- Background image -->
|
||||
{#if hasImage}
|
||||
<div class="absolute inset-0 z-0">
|
||||
{#if block.heroImage?.externalUrl}
|
||||
<img src={block.heroImage.externalUrl} alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{/if}
|
||||
<MedialibImage
|
||||
id={block.heroImage?.image || resolvedHeroImage?.id || resolvedHeroImage?._id || ""}
|
||||
entry={resolvedHeroImage}
|
||||
noPlaceholder
|
||||
style="width:100%;height:100%;object-fit:cover;"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-linear-to-b from-brand-950/80 via-brand-900/70 to-brand-950/90"></div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
import MedialibImage from "../widgets/MedialibImage.svelte"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
const resolvedImage = $derived(block._lookup?.image || null)
|
||||
|
||||
const paddingTop = $derived(
|
||||
block.padding?.top === "lg"
|
||||
? "pt-20"
|
||||
@@ -22,10 +25,10 @@
|
||||
: "pb-4"
|
||||
)
|
||||
|
||||
const hasImage = $derived(block.externalImageUrl || block.image)
|
||||
const hasImage = $derived(block.image || resolvedImage)
|
||||
const imageOnRight = $derived(block.imagePosition === "right")
|
||||
const imageOnLeft = $derived(block.imagePosition === "left")
|
||||
const showImage = $derived(hasImage && (imageOnRight || imageOnLeft))
|
||||
const showImage = $derived(hasImage && !!resolvedImage?.file?.src && (imageOnRight || imageOnLeft))
|
||||
</script>
|
||||
|
||||
<section data-block="richtext" class="richtext-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}>
|
||||
@@ -54,12 +57,14 @@
|
||||
</div>
|
||||
<div class:order-1={imageOnLeft} class="relative">
|
||||
<div class="rounded-2xl overflow-hidden shadow-xl shadow-brand-900/10">
|
||||
<img
|
||||
src={block.externalImageUrl || ""}
|
||||
alt={block.headline || ""}
|
||||
class="w-full h-auto object-cover aspect-4/3"
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if block.image || resolvedImage}
|
||||
<MedialibImage
|
||||
id={block.image || resolvedImage?.id || resolvedImage?._id || ""}
|
||||
entry={resolvedImage}
|
||||
noPlaceholder
|
||||
style="width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Decorative gradient behind image -->
|
||||
<div
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
}
|
||||
|
||||
/* ── Prose styling for CMS richtext blocks ───────────────────────── */
|
||||
.prose {
|
||||
color: var(--color-gray-900, #111827);
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface RevealOptions {
|
||||
}
|
||||
|
||||
export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
||||
if (node.closest("[data-admin-preview='true']")) {
|
||||
node.classList.add("reveal", "revealed")
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") return
|
||||
|
||||
const { delay = 0, threshold = 0.15, once = true } = options
|
||||
|
||||
@@ -16,6 +16,47 @@ export { locale, isLoading, addMessages } from "svelte-i18n"
|
||||
register("de", () => import("./locales/de.json"))
|
||||
register("en", () => import("./locales/en.json"))
|
||||
|
||||
let isI18nInitialized = false
|
||||
let syncSubscriptionsInitialized = false
|
||||
|
||||
function ensureI18nInitialized(initialLocale: SupportedLanguage = DEFAULT_LANGUAGE): void {
|
||||
if (isI18nInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
init({
|
||||
fallbackLocale: DEFAULT_LANGUAGE,
|
||||
initialLocale,
|
||||
})
|
||||
|
||||
isI18nInitialized = true
|
||||
}
|
||||
|
||||
function ensureLocaleSync(): void {
|
||||
if (syncSubscriptionsInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep svelte-i18n locale and selectedLanguage store in sync
|
||||
locale.subscribe((newLocale) => {
|
||||
if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) {
|
||||
selectedLanguage.set(newLocale as SupportedLanguage)
|
||||
}
|
||||
})
|
||||
|
||||
selectedLanguage.subscribe((newLang) => {
|
||||
const currentLocale = get(locale)
|
||||
if (newLang && newLang !== currentLocale) {
|
||||
locale.set(newLang)
|
||||
}
|
||||
})
|
||||
|
||||
syncSubscriptionsInitialized = true
|
||||
}
|
||||
|
||||
ensureI18nInitialized()
|
||||
ensureLocaleSync()
|
||||
|
||||
/**
|
||||
* Determine the initial locale from URL, browser, or fallback.
|
||||
*/
|
||||
@@ -50,26 +91,11 @@ function getInitialLocale(url?: string): SupportedLanguage {
|
||||
export async function setupI18n(url?: string): Promise<void> {
|
||||
const initialLocale = getInitialLocale(url)
|
||||
|
||||
init({
|
||||
fallbackLocale: DEFAULT_LANGUAGE,
|
||||
initialLocale,
|
||||
})
|
||||
ensureI18nInitialized(initialLocale)
|
||||
ensureLocaleSync()
|
||||
|
||||
selectedLanguage.set(initialLocale)
|
||||
|
||||
// Keep svelte-i18n locale and selectedLanguage store in sync
|
||||
locale.subscribe((newLocale) => {
|
||||
if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) {
|
||||
selectedLanguage.set(newLocale as SupportedLanguage)
|
||||
}
|
||||
})
|
||||
|
||||
selectedLanguage.subscribe((newLang) => {
|
||||
const currentLocale = get(locale)
|
||||
if (newLang && newLang !== currentLocale) {
|
||||
locale.set(newLang)
|
||||
}
|
||||
})
|
||||
locale.set(initialLocale)
|
||||
|
||||
await waitLocale()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"welcome": "Willkommen",
|
||||
"language": "Sprache",
|
||||
"skipToMainContent": "Zum Hauptinhalt springen",
|
||||
"scrollToTop": "Nach oben",
|
||||
"loading": "Laden…"
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"welcome": "Welcome",
|
||||
"language": "Language",
|
||||
"skipToMainContent": "Skip to main content",
|
||||
"scrollToTop": "Scroll to top",
|
||||
"loading": "Loading…"
|
||||
}
|
||||
+123
-5
@@ -14,11 +14,50 @@
|
||||
// Add new collections here as needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
import contentData from "../../mocking/content.json"
|
||||
import medialibData from "../../mocking/medialib.json"
|
||||
import navigationData from "../../mocking/navigation.json"
|
||||
|
||||
type EJsonObjectId = {
|
||||
$oid: string
|
||||
}
|
||||
|
||||
const mockRegistry: Record<string, Record<string, unknown>[]> = {
|
||||
content: contentData as Record<string, unknown>[],
|
||||
navigation: navigationData as Record<string, unknown>[],
|
||||
content: normalizeMockCollection(contentData as Record<string, unknown>[]),
|
||||
medialib: normalizeMockCollection(medialibData as Record<string, unknown>[]),
|
||||
navigation: normalizeMockCollection(navigationData as Record<string, unknown>[]),
|
||||
}
|
||||
|
||||
function isEJsonObjectId(value: unknown): value is EJsonObjectId {
|
||||
return !!value && typeof value === "object" && "$oid" in value && typeof (value as EJsonObjectId).$oid === "string"
|
||||
}
|
||||
|
||||
function normalizeMockCollection(entries: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
return entries.map((entry) => normalizeMockValue(entry))
|
||||
}
|
||||
|
||||
function normalizeMockValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeMockValue(item)) as T
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {}
|
||||
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
normalized[key] = normalizeMockValue(nestedValue)
|
||||
}
|
||||
|
||||
if (isEJsonObjectId(normalized._id)) {
|
||||
normalized._id = normalized._id.$oid
|
||||
}
|
||||
|
||||
if (typeof normalized._id === "string" && normalized.id === undefined) {
|
||||
normalized.id = normalized._id
|
||||
}
|
||||
|
||||
return normalized as T
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -49,9 +88,10 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u
|
||||
// --- Single-item retrieval ---
|
||||
if (itemId) {
|
||||
const item = sourceData.find((e) => e.id === itemId || e._id === itemId)
|
||||
const resultItem = item ? applyLookups(cloneEntry(item), options) : null
|
||||
return {
|
||||
data: item ?? null,
|
||||
count: item ? 1 : 0,
|
||||
data: resultItem,
|
||||
count: resultItem ? 1 : 0,
|
||||
buildTime: null,
|
||||
}
|
||||
}
|
||||
@@ -79,6 +119,8 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u
|
||||
results = results.slice(0, options.limit)
|
||||
}
|
||||
|
||||
results = results.map((entry) => applyLookups(cloneEntry(entry), options))
|
||||
|
||||
// Projection
|
||||
if (options?.projection) {
|
||||
results = applyProjection(results, options.projection)
|
||||
@@ -184,6 +226,81 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
}, obj)
|
||||
}
|
||||
|
||||
function cloneEntry<T>(entry: T): T {
|
||||
return JSON.parse(JSON.stringify(entry)) as T
|
||||
}
|
||||
|
||||
function applyLookups(entry: Record<string, unknown>, options?: ApiOptions): Record<string, unknown> {
|
||||
const lookupSpecs = parseLookupSpecs(options)
|
||||
if (!lookupSpecs.length) return entry
|
||||
|
||||
for (const spec of lookupSpecs) {
|
||||
const [fieldPath, collection] = spec.split(":")
|
||||
if (!fieldPath || !collection) continue
|
||||
|
||||
const lookupSource = mockRegistry[collection]
|
||||
if (!lookupSource) continue
|
||||
|
||||
applyLookupAtPath(entry, fieldPath.split("."), lookupSource)
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
function parseLookupSpecs(options?: ApiOptions): string[] {
|
||||
const rawLookup = [options?.lookup, options?.params?.lookup]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.flatMap((value) => value.split(","))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return Array.from(new Set(rawLookup))
|
||||
}
|
||||
|
||||
function applyLookupAtPath(
|
||||
current: Record<string, unknown>,
|
||||
pathSegments: string[],
|
||||
lookupSource: Record<string, unknown>[]
|
||||
): void {
|
||||
const [segment, ...rest] = pathSegments
|
||||
if (!segment) return
|
||||
|
||||
const value = current[segment]
|
||||
|
||||
if (rest.length === 0) {
|
||||
current._lookup = (current._lookup as Record<string, unknown> | undefined) || {}
|
||||
;(current._lookup as Record<string, unknown>)[segment] = resolveLookupValue(value, lookupSource)
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
if (item && typeof item === "object") {
|
||||
applyLookupAtPath(item as Record<string, unknown>, rest, lookupSource)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
applyLookupAtPath(value as Record<string, unknown>, rest, lookupSource)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLookupValue(value: unknown, lookupSource: Record<string, unknown>[]): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entryId) => resolveLookupById(entryId, lookupSource))
|
||||
}
|
||||
|
||||
return resolveLookupById(value, lookupSource)
|
||||
}
|
||||
|
||||
function resolveLookupById(value: unknown, lookupSource: Record<string, unknown>[]): Record<string, unknown> | null {
|
||||
if (typeof value !== "string") return null
|
||||
|
||||
return lookupSource.find((entry) => entry.id === value || entry._id === value) || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -244,7 +361,8 @@ function applyProjection(data: Record<string, unknown>[], projectionStr: string)
|
||||
if (field in entry) result[field] = entry[field]
|
||||
}
|
||||
// Always include id/_id
|
||||
if (entry.id !== undefined) result.id = entry.id
|
||||
if (typeof entry.id === "string") result.id = entry.id
|
||||
else if (typeof entry._id === "string") result.id = entry._id
|
||||
if (entry._id !== undefined) result._id = entry._id
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,47 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { getDBEntries, getDBEntry } from "../lib/api"
|
||||
import { apiBaseURL } from "../config"
|
||||
import { apiBaseOverride } from "../lib/store"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
// Medialib cache (module-level)
|
||||
const medialibCache: { [id: string]: MedialibEntry } = {}
|
||||
let loadQueue: string[] = []
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function processQueue() {
|
||||
if (loadQueue.length) {
|
||||
const _ids = [...loadQueue]
|
||||
loadQueue = []
|
||||
const entries = await getDBEntries(
|
||||
"medialib",
|
||||
{ _id: { $in: _ids } },
|
||||
"_id",
|
||||
undefined,
|
||||
undefined,
|
||||
"public"
|
||||
)
|
||||
entries.forEach((entry: MedialibEntry) => {
|
||||
if (entry.id) medialibCache[entry.id] = entry
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMedialibEntry(id: string): Promise<MedialibEntry> {
|
||||
if (medialibCache[id]) return medialibCache[id]
|
||||
loadQueue.push(id)
|
||||
await new Promise<void>((resolve) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(async () => {
|
||||
await processQueue()
|
||||
resolve()
|
||||
}, 50)
|
||||
})
|
||||
return medialibCache[id]
|
||||
}
|
||||
import { currentLanguage, DEFAULT_LANGUAGE } from "../lib/i18n"
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
entry?: MedialibEntry | null
|
||||
filter?: string | null
|
||||
noPlaceholder?: boolean
|
||||
caption?: string
|
||||
@@ -54,6 +17,7 @@
|
||||
|
||||
let {
|
||||
id,
|
||||
entry = null,
|
||||
filter = null,
|
||||
noPlaceholder = false,
|
||||
caption = "",
|
||||
@@ -64,11 +28,10 @@
|
||||
style = "",
|
||||
}: Props = $props()
|
||||
|
||||
let loading = $state(true)
|
||||
let entry = $state<MedialibEntry | null>(null)
|
||||
let fileSrc = $state<string | null>(null)
|
||||
let imgEl = $state<HTMLImageElement | null>(null)
|
||||
let currentFilter = $state<string>("l-webp")
|
||||
const effectiveId = $derived(entry?.id || entry?._id || id || "")
|
||||
const fileSrc = $derived(resolveFileSrc(entry?.file?.src, entry?.id || entry?._id || effectiveId))
|
||||
|
||||
// Sync explicit filter prop reactively
|
||||
$effect(() => {
|
||||
@@ -94,34 +57,14 @@
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadFile() {
|
||||
if (!id) return
|
||||
loading = true
|
||||
entry = null
|
||||
fileSrc = null
|
||||
try {
|
||||
const _apiBase = get(apiBaseOverride) || apiBaseURL
|
||||
entry =
|
||||
typeof window !== "undefined"
|
||||
? await loadMedialibEntry(id)
|
||||
: await getDBEntry("medialib", { _id: id }, "public")
|
||||
if (entry?.file?.src) {
|
||||
fileSrc = _apiBase + "medialib/" + id + "/" + entry.file.src
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
loading = false
|
||||
function resolveFileSrc(src: string | undefined, entryId: string | undefined): string | null {
|
||||
if (!src) return null
|
||||
if (/^(https?:)?\/\//.test(src) || src.startsWith("/")) return src
|
||||
if (!entryId) return null
|
||||
const normalizedApiBase = apiBaseURL.replace(/\/+$/, "")
|
||||
return `${normalizedApiBase}/medialib/${entryId}/${src.replace(/^\/+/, "")}`
|
||||
}
|
||||
|
||||
// SSR: fire-and-forget — $effect does NOT run during SSR.
|
||||
// loadFile() internally checks if id is set.
|
||||
if (typeof window === "undefined") loadFile()
|
||||
|
||||
$effect(() => {
|
||||
if (id) loadFile()
|
||||
})
|
||||
|
||||
// ResizeObserver: only when no explicit filter and raster image
|
||||
$effect(() => {
|
||||
const el = imgEl
|
||||
@@ -147,21 +90,24 @@
|
||||
if (filter) return src + `?filter=${filter}`
|
||||
return src + `?filter=${currentFilter}`
|
||||
}
|
||||
|
||||
function resolveLocalizedText(value: string | LocalizedText | undefined, lang: string): string {
|
||||
if (!value) return ""
|
||||
if (typeof value === "string") return value
|
||||
|
||||
return value[lang] || value[DEFAULT_LANGUAGE] || Object.values(value).find((entry) => !!entry) || ""
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if id}
|
||||
{#if loading}
|
||||
{#if !noPlaceholder}
|
||||
<img src="/assets/img/placeholder-image.svg" alt="loading" />
|
||||
{/if}
|
||||
{:else if entry && fileSrc}
|
||||
{#if effectiveId}
|
||||
{#if entry && fileSrc}
|
||||
{#if showCaption && caption}
|
||||
<figure>
|
||||
<picture>
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
src={getSrc(fileSrc, entry)}
|
||||
alt={entry.alt || ""}
|
||||
alt={resolveLocalizedText(entry.alt, $currentLanguage)}
|
||||
data-entry-id={id}
|
||||
loading={lazy ? "lazy" : undefined}
|
||||
{style}
|
||||
@@ -176,7 +122,7 @@
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
src={getSrc(fileSrc, entry)}
|
||||
alt={entry.alt || ""}
|
||||
alt={resolveLocalizedText(entry.alt, $currentLanguage)}
|
||||
data-entry-id={id}
|
||||
loading={lazy ? "lazy" : undefined}
|
||||
{style}
|
||||
@@ -185,7 +131,7 @@
|
||||
{/if}
|
||||
{:else if !noPlaceholder}
|
||||
<picture>
|
||||
<img src="/assets/img/placeholder-image.svg" alt="not found" data-entry-id={id} />
|
||||
<img src="/assets/img/placeholder-image.svg" alt="not found" data-entry-id={effectiveId} />
|
||||
</picture>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"start:mock": "MOCK=1 node scripts/esbuild-wrapper.js start",
|
||||
"start:ssr": "SSR=1 node scripts/esbuild-wrapper.js start",
|
||||
"build": "node scripts/esbuild-wrapper.js build",
|
||||
"build:admin": "node scripts/esbuild-wrapper.js build esbuild.config.admin.js",
|
||||
"build:server": "node scripts/esbuild-wrapper.js build esbuild.config.server.js && babel --config-file ./babel.config.server.json _temp/app.server.js -o _temp/app.server.babeled.js && esbuild _temp/app.server.babeled.js --outfile=api/hooks/lib/app.server.js --bundle --sourcemap --platform=node --banner:js='// @ts-nocheck'",
|
||||
"test": "playwright test",
|
||||
"test:e2e": "playwright test tests/e2e",
|
||||
|
||||
@@ -16,10 +16,20 @@ function log(str, clear) {
|
||||
let buildResults
|
||||
let ctx
|
||||
|
||||
function cleanChunks() {
|
||||
const outdir = config.options.outdir
|
||||
if (!outdir) return
|
||||
const chunksDir = path.join(outdir, "chunks")
|
||||
if (fs.existsSync(chunksDir)) {
|
||||
fs.rmSync(chunksDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function build(catchError) {
|
||||
if (config.writeBuildInfo) {
|
||||
config.writeBuildInfo()
|
||||
}
|
||||
cleanChunks()
|
||||
if (!ctx) ctx = await esbuild.context(config.options)
|
||||
log((buildResults ? "re" : "") + "building...")
|
||||
const timerStart = Date.now()
|
||||
@@ -76,6 +86,7 @@ switch (process.argv?.length > 2 ? process.argv[2] : "build") {
|
||||
if (config.writeBuildInfo) {
|
||||
config.writeBuildInfo()
|
||||
}
|
||||
cleanChunks()
|
||||
esbuild.build(config.options).then(function (buildResults) {
|
||||
if (config.options.metafile) {
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -36,4 +36,20 @@ test.describe("Home Page", () => {
|
||||
expect(page.url()).toContain("/en")
|
||||
}
|
||||
})
|
||||
|
||||
test("should allow skipping directly to main content", async ({ page }) => {
|
||||
await page.goto("/de/")
|
||||
await waitForSpaReady(page)
|
||||
|
||||
await page.keyboard.press("Tab")
|
||||
|
||||
const skipLink = page.getByRole("button", { name: "Zum Hauptinhalt springen" })
|
||||
await expect(skipLink).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const mainContent = page.locator("main#main-content")
|
||||
await expect(mainContent).toBeFocused()
|
||||
await expect(page).toHaveURL(/#main-content$/)
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+30
-27
@@ -50,18 +50,39 @@ interface FileField {
|
||||
size: number
|
||||
}
|
||||
|
||||
type LocalizedText = {
|
||||
[lang: string]: string | undefined
|
||||
}
|
||||
|
||||
interface MedialibEntry {
|
||||
id?: string
|
||||
_id?: string
|
||||
file?: {
|
||||
src?: string
|
||||
type?: string
|
||||
}
|
||||
alt?: string
|
||||
title?: string
|
||||
alt?: string | LocalizedText
|
||||
description?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface LookupContainer<T> {
|
||||
_lookup?: Record<string, T | null>
|
||||
}
|
||||
|
||||
interface ContentHeroImage extends LookupContainer<MedialibEntry> {
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface ContentFeatureBoxEntry {
|
||||
icon?: "lightning" | "palette" | "database" | "globe" | "monitor" | "flask"
|
||||
title?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
/** Pagebuilder: Content Block Entry */
|
||||
interface ContentBlockEntry {
|
||||
interface ContentBlockEntry extends LookupContainer<MedialibEntry> {
|
||||
hide?: boolean
|
||||
headline?: string
|
||||
headlineH1?: boolean
|
||||
@@ -69,10 +90,6 @@ interface ContentBlockEntry {
|
||||
tagline?: string
|
||||
anchorId?: string
|
||||
containerWidth?: "" | "wide" | "full"
|
||||
background?: {
|
||||
color?: string
|
||||
image?: string
|
||||
}
|
||||
padding?: {
|
||||
top?: string
|
||||
bottom?: string
|
||||
@@ -83,41 +100,25 @@ interface ContentBlockEntry {
|
||||
buttonLink?: string
|
||||
buttonTarget?: string
|
||||
}
|
||||
heroImage?: {
|
||||
image?: string
|
||||
/** External image URL (e.g. Unsplash) — used when no medialib ID is available */
|
||||
externalUrl?: string
|
||||
}
|
||||
heroImage?: ContentHeroImage
|
||||
featureBoxes?: ContentFeatureBoxEntry[]
|
||||
// richtext fields
|
||||
text?: string
|
||||
imagePosition?: "none" | "left" | "right"
|
||||
imageRounded?: string
|
||||
image?: string
|
||||
/** External image URL for richtext/generic blocks (e.g. Unsplash) */
|
||||
externalImageUrl?: string
|
||||
// accordion fields
|
||||
accordionItems?: {
|
||||
question?: string
|
||||
answer?: string
|
||||
open?: boolean
|
||||
}[]
|
||||
// imageGallery fields
|
||||
imageGallery?: {
|
||||
images?: {
|
||||
image?: string
|
||||
caption?: string
|
||||
showCaption?: boolean
|
||||
}[]
|
||||
}
|
||||
// richtext caption fields
|
||||
showImageCaption?: boolean
|
||||
imageCaption?: string
|
||||
}
|
||||
|
||||
/** Content Entry from the CMS */
|
||||
interface ContentEntry {
|
||||
id?: string
|
||||
_id?: string
|
||||
_lookup?: Record<string, MedialibEntry | MedialibEntry[] | null>
|
||||
active?: boolean
|
||||
publication?: {
|
||||
from?: string | Date
|
||||
@@ -129,13 +130,12 @@ interface ContentEntry {
|
||||
name?: string
|
||||
path?: string
|
||||
alternativePaths?: { path?: string }[]
|
||||
thumbnail?: string
|
||||
teaserText?: string
|
||||
blocks?: ContentBlockEntry[]
|
||||
meta?: {
|
||||
title?: string
|
||||
description?: string
|
||||
keywords?: string
|
||||
keywords?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,9 @@ interface NavigationElement {
|
||||
page?: string
|
||||
hash?: string
|
||||
externalUrl?: string
|
||||
_lookup?: {
|
||||
page?: ContentEntry | null
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigation entry from the CMS */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { moveThenClick, moveThenType, smoothScroll } from "../helpers"
|
||||
* Video tour: Full demo showcase
|
||||
*
|
||||
* Walks through all demo pages, demonstrating:
|
||||
* 1. Homepage — hero, features, richtext, FAQ accordion
|
||||
* 1. Homepage — hero, richtext sections, FAQ accordion
|
||||
* 2. About page — content blocks
|
||||
* 3. Contact page — form interaction
|
||||
* 4. Language switching (EN ↔ DE with route translation)
|
||||
@@ -20,11 +20,11 @@ tour("Demo Showcase", async ({ tourPage: page }) => {
|
||||
await page.goto("/de/")
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
// Scroll down through hero → features
|
||||
// Scroll down through hero → first richtext section
|
||||
await smoothScroll(page, 500)
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Scroll through features
|
||||
// Scroll through the first richtext section
|
||||
await smoothScroll(page, 1200)
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user