diff --git a/.agents/skills/admin-ui-config/SKILL.md b/.agents/skills/admin-ui-config/SKILL.md
index edacd2f..531f9ab 100644
--- a/.agents/skills/admin-ui-config/SKILL.md
+++ b/.agents/skills/admin-ui-config/SKILL.md
@@ -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.
diff --git a/.agents/skills/content-authoring/SKILL.md b/.agents/skills/content-authoring/SKILL.md
index 05b6c1c..fe65895 100644
--- a/.agents/skills/content-authoring/SKILL.md
+++ b/.agents/skills/content-authoring/SKILL.md
@@ -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 |
diff --git a/.agents/skills/tibi-project-setup/SKILL.md b/.agents/skills/tibi-project-setup/SKILL.md
index 0737f81..a39b381 100644
--- a/.agents/skills/tibi-project-setup/SKILL.md
+++ b/.agents/skills/tibi-project-setup/SKILL.md
@@ -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)
```
diff --git a/.env b/.env
index a8df2a9..66ba2cd 100644
--- a/.env
+++ b/.env
@@ -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
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index ed1378c..cc19b00 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -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"
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9279254..af40fa0 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -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": [
diff --git a/Makefile b/Makefile
index 11b32ec..3a40137 100644
--- a/Makefile
+++ b/Makefile
@@ -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) $*
diff --git a/README.md b/README.md
index 7a0655b..99e3302 100644
--- a/README.md
+++ b/README.md
@@ -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 mit
[ ] 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
- `` 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`.
diff --git a/api/collections/content.yml b/api/collections/content.yml
index b08adc5..3e96e4b 100644
--- a/api/collections/content.yml
+++ b/api/collections/content.yml
@@ -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" }
diff --git a/api/collections/medialib.yml b/api/collections/medialib.yml
new file mode 100644
index 0000000..7503bcb
--- /dev/null
+++ b/api/collections/medialib.yml
@@ -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
diff --git a/api/collections/navigation.yml b/api/collections/navigation.yml
index 5cc588b..5d6b2c5 100644
--- a/api/collections/navigation.yml
+++ b/api/collections/navigation.yml
@@ -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" }
diff --git a/api/collections/ssr.yml b/api/collections/ssr.yml
index 64037a7..09803a6 100644
--- a/api/collections/ssr.yml
+++ b/api/collections/ssr.yml
@@ -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:
diff --git a/api/config.yml b/api/config.yml
index 1c61b94..60f7123 100644
--- a/api/config.yml
+++ b/api/config.yml
@@ -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: |
- " "
+ 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
diff --git a/esbuild.config.js b/esbuild.config.js
index accb2d5..c9f18f8 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -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",
},
diff --git a/frontend/.htaccess b/frontend/.htaccess
index 6120167..b2ed3ff 100644
--- a/frontend/.htaccess
+++ b/frontend/.htaccess
@@ -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]
diff --git a/frontend/assets/fonts/.gitkeep b/frontend/assets/fonts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/assets/img/admin-pic.svg b/frontend/assets/img/admin-pic.svg
new file mode 100644
index 0000000..1a4e32b
--- /dev/null
+++ b/frontend/assets/img/admin-pic.svg
@@ -0,0 +1,29 @@
+
+ Tibi Starter admin preview
+ Abstract admin preview illustration for the Tibi starter project.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tibi Admin Nova Starter
+ Lean starter base with content, media, i18n, and SSR.
+
diff --git a/frontend/assets/img/placeholder-image.svg b/frontend/assets/img/placeholder-image.svg
new file mode 100644
index 0000000..5bd0e60
--- /dev/null
+++ b/frontend/assets/img/placeholder-image.svg
@@ -0,0 +1,16 @@
+
+ Placeholder image
+ Neutral placeholder graphic for missing media assets.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/assets/img/safari-pinned-tab.svg b/frontend/assets/img/safari-pinned-tab.svg
new file mode 100644
index 0000000..fdbef6e
--- /dev/null
+++ b/frontend/assets/img/safari-pinned-tab.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/frontend/assets/img/site.webmanifest b/frontend/assets/img/site.webmanifest
new file mode 100644
index 0000000..6eb94ef
--- /dev/null
+++ b/frontend/assets/img/site.webmanifest
@@ -0,0 +1,8 @@
+{
+ "name": "Tibi Svelte Starter",
+ "short_name": "Tibi Starter",
+ "icons": [],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/frontend/mocking/content.json b/frontend/mocking/content.json
index d19557f..b768500 100644
--- a/frontend/mocking/content.json
+++ b/frontend/mocking/content.json
@@ -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": "Svelte 5 Runes Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance.
Tailwind CSS 4 Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds.
Tibi CMS API Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung.
i18n Built-in Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel.
SSR via goja Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung.
Playwright Tests E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go.
"
+ "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": "Starte die Entwicklungsumgebung mit make docker-up && make docker-start. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.
Für Offline-Entwicklung aktiviere den Mock-Modus mit MOCK=1 in der .env. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.
Jeder Block-Typ (Hero, Richtext, Accordion, Features) ist eine eigene Svelte-Komponente — erweiterbar und austauschbar .
"
+ "text": "Starte die Entwicklungsumgebung mit make docker-up && make docker-start. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.
Für Offline-Entwicklung aktiviere den Mock-Modus mit MOCK=1 in der .env. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.
Jeder Block-Typ (Hero, Richtext, Accordion, Kontaktformular) ist eine eigene Svelte-Komponente — erweiterbar und austauschbar .
"
},
{
"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": "Svelte 5 Runes Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance.
Tailwind CSS 4 Utility-first styling with custom theme, dark-mode-ready and blazing fast builds.
Tibi CMS API Collections, hooks, media library — all via REST API. With mock mode for offline development.
Built-in i18n Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible.
SSR via goja Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation.
Playwright Tests E2E, API, visual regression and video tours — all preconfigured and ready to go.
"
+ "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": "Start the dev environment with make docker-up && make docker-start. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.
For offline development, enable mock mode with MOCK=1 in .env. Content is loaded via the Tibi API and rendered with the BlockRenderer.
Each block type (Hero, Richtext, Accordion, Features) is its own Svelte component — extensible and swappable .
"
+ "text": "Start the dev environment with make docker-up && make docker-start. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.
For offline development, enable mock mode with MOCK=1 in .env. Content is loaded via the Tibi API and rendered with the BlockRenderer.
Each block type (Hero, Richtext, Accordion, Contact Form) is its own Svelte component — extensible and swappable .
"
},
{
"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": "Jede Komponente wurde sorgfältig ausgewählt:
Svelte 5 — Reaktives Framework mit Runes-APITailwind CSS 4 — Utility-first CSS mit @themeesbuild — Extrem schneller BundlerTibi CMS — Headless CMS mit Go-Backendgoja SSR — Server-Side Rendering in GoPlaywright — Modernes Testing-Framework "
}
@@ -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": "Every component was carefully chosen:
Svelte 5 — Reactive framework with Runes APITailwind CSS 4 — Utility-first CSS with @themeesbuild — Extremely fast bundlerTibi CMS — Headless CMS with Go backendgoja SSR — Server-side rendering in GoPlaywright — Modern testing framework "
}
@@ -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"
+ ]
}
}
]
\ No newline at end of file
diff --git a/frontend/mocking/medialib.json b/frontend/mocking/medialib.json
new file mode 100644
index 0000000..44c4740
--- /dev/null
+++ b/frontend/mocking/medialib.json
@@ -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"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/frontend/mocking/navigation.json b/frontend/mocking/navigation.json
index e198d9e..c73fe4c 100644
--- a/frontend/mocking/navigation.json
+++ b/frontend/mocking/navigation.json
@@ -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"
+ }
]
}
-]
+]
\ No newline at end of file
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 9e65421..f4011af 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -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(null)
let headerNav = $state(null)
let footerNav = $state(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}
{/if}
+ {#if contentEntry?.meta?.keywords?.length}
+
+ {/if}
@@ -168,6 +250,15 @@
? 'bg-white/80 backdrop-blur-lg shadow-sm'
: 'bg-transparent'}"
>
+
+ {$_("skipToMainContent")}
+
+
{item.name}
@@ -273,7 +364,7 @@
-
+
{#if loading}
@@ -319,7 +410,7 @@
{:else}
{item.name}
diff --git a/frontend/src/admin.ts b/frontend/src/admin.ts
index 69acad0..f6193db 100644
--- a/frontend/src/admin.ts
+++ b/frontend/src/admin.ts
@@ -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, context?: BlockRenderContext): void
+ destroy(): void
+}
+
+type BlockDefinition = {
+ render(container: HTMLElement | ShadowRoot, row: Record, context?: BlockRenderContext): BlockHandle
+ css?: string[]
+ previewStyles?: Record
+ 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, {
+ target,
+ props: {
+ blocks: [row as ContentBlockEntry],
+ isAdminPreview: true,
+ },
+ })
+
+ return {
+ update(nextRow) {
+ unmount(mountedComponent)
+ target.innerHTML = ""
+ mountedComponent = mount(BlockRenderer as Component, {
+ 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
}
diff --git a/frontend/src/blocks/FeatureIcon.svelte b/frontend/src/blocks/FeatureIcon.svelte
new file mode 100644
index 0000000..7df5eb1
--- /dev/null
+++ b/frontend/src/blocks/FeatureIcon.svelte
@@ -0,0 +1,99 @@
+
+
+{#if icon === "palette"}
+
+
+
+
+
+
+
+{:else if icon === "database"}
+
+
+
+
+
+
+{:else if icon === "globe"}
+
+
+
+
+
+{:else if icon === "monitor"}
+
+
+
+
+
+{:else if icon === "flask"}
+
+
+
+
+{:else}
+
+
+
+{/if}
diff --git a/frontend/src/blocks/FeaturesBlock.svelte b/frontend/src/blocks/FeaturesBlock.svelte
index efad3dd..088af2e 100644
--- a/frontend/src/blocks/FeaturesBlock.svelte
+++ b/frontend/src/blocks/FeaturesBlock.svelte
@@ -1,5 +1,6 @@
@@ -17,9 +23,12 @@
{#if hasImage}
- {#if block.heroImage?.externalUrl}
-
- {/if}
+
{:else}
diff --git a/frontend/src/blocks/RichtextBlock.svelte b/frontend/src/blocks/RichtextBlock.svelte
index 06ebfe2..b559453 100644
--- a/frontend/src/blocks/RichtextBlock.svelte
+++ b/frontend/src/blocks/RichtextBlock.svelte
@@ -1,8 +1,11 @@
-
+ {#if block.image || resolvedImage}
+
+ {/if}
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
{
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()
}
diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json
index f90e915..f643c90 100644
--- a/frontend/src/lib/i18n/locales/de.json
+++ b/frontend/src/lib/i18n/locales/de.json
@@ -50,6 +50,7 @@
},
"welcome": "Willkommen",
"language": "Sprache",
+ "skipToMainContent": "Zum Hauptinhalt springen",
"scrollToTop": "Nach oben",
"loading": "Laden…"
}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json
index b09d3e3..f2dfe40 100644
--- a/frontend/src/lib/i18n/locales/en.json
+++ b/frontend/src/lib/i18n/locales/en.json
@@ -50,6 +50,7 @@
},
"welcome": "Welcome",
"language": "Language",
+ "skipToMainContent": "Skip to main content",
"scrollToTop": "Scroll to top",
"loading": "Loading…"
}
\ No newline at end of file
diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts
index 38534cd..9aacaba 100644
--- a/frontend/src/lib/mock.ts
+++ b/frontend/src/lib/mock.ts
@@ -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[]> = {
- content: contentData as Record[],
- navigation: navigationData as Record[],
+ content: normalizeMockCollection(contentData as Record[]),
+ medialib: normalizeMockCollection(medialibData as Record[]),
+ navigation: normalizeMockCollection(navigationData as Record[]),
+}
+
+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[]): Record[] {
+ return entries.map((entry) => normalizeMockValue(entry))
+}
+
+function normalizeMockValue(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 = {}
+ for (const [key, nestedValue] of Object.entries(value as Record)) {
+ 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, path: string): unknown {
}, obj)
}
+function cloneEntry(entry: T): T {
+ return JSON.parse(JSON.stringify(entry)) as T
+}
+
+function applyLookups(entry: Record, options?: ApiOptions): Record {
+ 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,
+ pathSegments: string[],
+ lookupSource: Record[]
+): void {
+ const [segment, ...rest] = pathSegments
+ if (!segment) return
+
+ const value = current[segment]
+
+ if (rest.length === 0) {
+ current._lookup = (current._lookup as Record | undefined) || {}
+ ;(current._lookup as Record)[segment] = resolveLookupValue(value, lookupSource)
+ return
+ }
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => {
+ if (item && typeof item === "object") {
+ applyLookupAtPath(item as Record, rest, lookupSource)
+ }
+ })
+ return
+ }
+
+ if (value && typeof value === "object") {
+ applyLookupAtPath(value as Record, rest, lookupSource)
+ }
+}
+
+function resolveLookupValue(value: unknown, lookupSource: Record[]): unknown {
+ if (Array.isArray(value)) {
+ return value.map((entryId) => resolveLookupById(entryId, lookupSource))
+ }
+
+ return resolveLookupById(value, lookupSource)
+}
+
+function resolveLookupById(value: unknown, lookupSource: Record[]): Record | 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[], 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
}
diff --git a/frontend/src/widgets/MedialibImage.svelte b/frontend/src/widgets/MedialibImage.svelte
index fe1d11f..2844060 100644
--- a/frontend/src/widgets/MedialibImage.svelte
+++ b/frontend/src/widgets/MedialibImage.svelte
@@ -1,47 +1,10 @@
-{#if id}
- {#if loading}
- {#if !noPlaceholder}
-
- {/if}
- {:else if entry && fileSrc}
+{#if effectiveId}
+ {#if entry && fileSrc}
{#if showCaption && caption}
-
+
{/if}
{/if}
diff --git a/package.json b/package.json
index 1eaee90..b473188 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/esbuild-wrapper.js b/scripts/esbuild-wrapper.js
index cd5f997..402033a 100644
--- a/scripts/esbuild-wrapper.js
+++ b/scripts/esbuild-wrapper.js
@@ -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(
diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts
index 5d58a1a..ceec541 100644
--- a/tests/e2e/home.spec.ts
+++ b/tests/e2e/home.spec.ts
@@ -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$/)
+ })
})
diff --git a/types/global.d.ts b/types/global.d.ts
index 496ae69..05778c7 100644
--- a/types/global.d.ts
+++ b/types/global.d.ts
@@ -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 {
+ _lookup?: Record
+}
+
+interface ContentHeroImage extends LookupContainer {
+ 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 {
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
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 */
diff --git a/video-tours/tours/demo-showcase.tour.ts b/video-tours/tours/demo-showcase.tour.ts
index 10b9a4e..bb18211 100644
--- a/video-tours/tours/demo-showcase.tour.ts
+++ b/video-tours/tours/demo-showcase.tour.ts
@@ -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)